diff --git a/apps/chat/__pycache__/__init__.cpython-313.pyc b/apps/chat/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9c11b90 Binary files /dev/null and b/apps/chat/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/__pycache__/admin.cpython-313.pyc b/apps/chat/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..01007d7 Binary files /dev/null and b/apps/chat/__pycache__/admin.cpython-313.pyc differ diff --git a/apps/chat/__pycache__/apps.cpython-313.pyc b/apps/chat/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000..f270aaf Binary files /dev/null and b/apps/chat/__pycache__/apps.cpython-313.pyc differ diff --git a/apps/chat/__pycache__/urls.cpython-313.pyc b/apps/chat/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..dee0d90 Binary files /dev/null and b/apps/chat/__pycache__/urls.cpython-313.pyc differ diff --git a/apps/chat/api/__init__.py b/apps/chat/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/api/__pycache__/__init__.cpython-313.pyc b/apps/chat/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6a8d32f Binary files /dev/null and b/apps/chat/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/api/__pycache__/urls.cpython-313.pyc b/apps/chat/api/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..c46a4d2 Binary files /dev/null and b/apps/chat/api/__pycache__/urls.cpython-313.pyc differ diff --git a/apps/chat/api/urls.py b/apps/chat/api/urls.py new file mode 100644 index 0000000..83b6548 --- /dev/null +++ b/apps/chat/api/urls.py @@ -0,0 +1,16 @@ +from django.urls import path + +from apps.chat.api.views.conversations import ( + ConversationCreateView, + UserConversationListView, +) +from apps.chat.api.views.messages import MessageView +from apps.chat.api.views.read_state import ChatEventsView, ReadStateView + +urlpatterns = [ + path("chats/", ConversationCreateView.as_view(), name="chat-create"), + path("users//chats/", UserConversationListView.as_view(), name="user-chat-list"), + path("chats//messages/", MessageView.as_view(), name="chat-messages"), + path("chats//read/", ReadStateView.as_view(), name="chat-read"), + path("chats//events/", ChatEventsView.as_view(), name="chat-events"), +] diff --git a/apps/chat/api/views/__init__.py b/apps/chat/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc b/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e8241c3 Binary files /dev/null and b/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc new file mode 100644 index 0000000..6750f1c Binary files /dev/null and b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc differ diff --git a/apps/chat/api/views/__pycache__/messages.cpython-313.pyc b/apps/chat/api/views/__pycache__/messages.cpython-313.pyc new file mode 100644 index 0000000..1a33037 Binary files /dev/null and b/apps/chat/api/views/__pycache__/messages.cpython-313.pyc differ diff --git a/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc new file mode 100644 index 0000000..9abd17f Binary files /dev/null and b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc differ diff --git a/apps/chat/api/views/conversations.py b/apps/chat/api/views/conversations.py new file mode 100644 index 0000000..f147e29 --- /dev/null +++ b/apps/chat/api/views/conversations.py @@ -0,0 +1,72 @@ +from uuid import UUID + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.chat.models import ConversationParticipant +from apps.chat.serializers.conversations import ( + ConversationListSerializer, + ConversationSerializer, +) +from apps.chat.services.conversation import ConversationService +from apps.chat.services.message import MessageService +from apps.chat.services.read_state import ReadStateService + + +class ConversationCreateView(APIView): + def post(self, request): + user_1_id = UUID(request.data["user_1_id"]) + user_2_id = UUID(request.data["user_2_id"]) + + conversation = ConversationService().create(user_1_id, user_2_id) + + return Response( + ConversationSerializer(conversation).data, + status=status.HTTP_201_CREATED, + ) + + +class UserConversationListView(APIView): + def get(self, request, user_id): + uid = UUID(str(user_id)) + page = int(request.query_params.get("page", 0)) + page_size = int(request.query_params.get("page_size", 20)) + + conv_svc = ConversationService() + read_svc = ReadStateService() + msg_svc = MessageService() + + conversations = conv_svc.list_for_user(uid, page, page_size) + + result = [] + for conv in conversations: + participants = list( + ConversationParticipant.objects.filter(conversation_id=conv.id) + .order_by("joined_at") + .values_list("user_id", flat=True) + ) + + try: + has_unread = read_svc.has_unread(conv.id, uid) + except Exception: + has_unread = False + + try: + last_msgs = msg_svc.list_messages(conv.id, page=0, per_page=1) + last_message = last_msgs[0] if last_msgs else None + except Exception: + last_message = None + + result.append( + { + "id": conv.id, + "type": conv.type, + "created_at": conv.created_at, + "participants": participants, + "has_unread": has_unread, + "last_message": last_message, + } + ) + + return Response(ConversationListSerializer(result, many=True).data) diff --git a/apps/chat/api/views/messages.py b/apps/chat/api/views/messages.py new file mode 100644 index 0000000..bd18b25 --- /dev/null +++ b/apps/chat/api/views/messages.py @@ -0,0 +1,41 @@ +from uuid import UUID + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.chat.serializers.messages import MessageSerializer +from apps.chat.services.message import MessageService + + +class MessageView(APIView): + def post(self, request, chat_id): + sender_id = UUID(request.data["sender_id"]) + message_type = request.data.get("message_type", "text") + text = request.data.get("text") + file = request.FILES.get("file") + + result = MessageService().send( + conversation_id=UUID(str(chat_id)), + sender_id=sender_id, + message_type=message_type, + text=text, + file=file, + ) + + return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED) + + def get(self, request, chat_id): + page = int(request.query_params.get("page", 0)) + per_page = int(request.query_params.get("per_page", 20)) + since_raw = request.query_params.get("since") + since = int(since_raw) if since_raw else None + + messages = MessageService().list_messages( + conversation_id=UUID(str(chat_id)), + page=page, + per_page=per_page, + since=since, + ) + + return Response(MessageSerializer(messages, many=True).data) diff --git a/apps/chat/api/views/read_state.py b/apps/chat/api/views/read_state.py new file mode 100644 index 0000000..a1b1a3e --- /dev/null +++ b/apps/chat/api/views/read_state.py @@ -0,0 +1,46 @@ +import json +from uuid import UUID + +from django.conf import settings +from django_redis import get_redis_connection +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.chat.serializers.read_state import ReadStateSerializer +from apps.chat.services.read_state import ReadStateService + + +class ReadStateView(APIView): + def post(self, request, chat_id): + user_id = UUID(request.data["user_id"]) + post_id = request.data["post_id"] + + read_state = ReadStateService().mark_read( + conversation_id=UUID(str(chat_id)), + user_id=user_id, + post_id=post_id, + ) + + data = { + "conversation_id": read_state.conversation_id, + "user_id": read_state.user_id, + "has_unread": False, + } + return Response(ReadStateSerializer(data).data) + + +class ChatEventsView(APIView): + def get(self, request, chat_id): + timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25) + + redis = get_redis_connection("default") + key = f"chat_events:{chat_id}" + + result = redis.blpop(key, timeout=timeout) + + if result is None: + return Response([]) + + _, raw = result + event = json.loads(raw) + return Response([event]) diff --git a/apps/chat/apps.py b/apps/chat/apps.py index 45f098c..401333b 100644 --- a/apps/chat/apps.py +++ b/apps/chat/apps.py @@ -3,3 +3,4 @@ from django.apps import AppConfig class ChatConfig(AppConfig): name = 'apps.chat' + default_auto_field = 'django.db.models.BigAutoField' diff --git a/apps/chat/events/__init__.py b/apps/chat/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/events/__pycache__/__init__.cpython-313.pyc b/apps/chat/events/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fb5fe1e Binary files /dev/null and b/apps/chat/events/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/events/__pycache__/event.cpython-313.pyc b/apps/chat/events/__pycache__/event.cpython-313.pyc new file mode 100644 index 0000000..720cc71 Binary files /dev/null and b/apps/chat/events/__pycache__/event.cpython-313.pyc differ diff --git a/apps/chat/events/base.py b/apps/chat/events/base.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/events/event.py b/apps/chat/events/event.py new file mode 100644 index 0000000..5d4d448 --- /dev/null +++ b/apps/chat/events/event.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass +class MessageSentEvent: + chat_id: UUID + post_id: str + sender_id: UUID + message_type: str + payload: dict diff --git a/apps/chat/events/publishers/__init__.py b/apps/chat/events/publishers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..f2aa4a8 Binary files /dev/null and b/apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc new file mode 100644 index 0000000..7c86df2 Binary files /dev/null and b/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc differ diff --git a/apps/chat/events/publishers/__pycache__/push.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/push.cpython-313.pyc new file mode 100644 index 0000000..759cca1 Binary files /dev/null and b/apps/chat/events/publishers/__pycache__/push.cpython-313.pyc differ diff --git a/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc new file mode 100644 index 0000000..89ddeee Binary files /dev/null and b/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc differ diff --git a/apps/chat/events/publishers/longpoll.py b/apps/chat/events/publishers/longpoll.py new file mode 100644 index 0000000..101487f --- /dev/null +++ b/apps/chat/events/publishers/longpoll.py @@ -0,0 +1,23 @@ +import json + +from django_redis import get_redis_connection + +from apps.chat.events.event import MessageSentEvent + +_KEY_TTL = 300 + + +class LongPollPublisher: + def publish(self, event: MessageSentEvent) -> None: + redis = get_redis_connection("default") + key = f"chat_events:{event.chat_id}" + payload = json.dumps( + { + "post_id": event.post_id, + "sender_id": str(event.sender_id), + "message_type": event.message_type, + **{k: v for k, v in event.payload.items() if v is not None}, + } + ) + redis.rpush(key, payload) + redis.expire(key, _KEY_TTL) diff --git a/apps/chat/events/publishers/push.py b/apps/chat/events/publishers/push.py new file mode 100644 index 0000000..fbba604 --- /dev/null +++ b/apps/chat/events/publishers/push.py @@ -0,0 +1,7 @@ +from apps.chat.events.event import MessageSentEvent + + +class PushPublisher: + # Future: FCM / APNS integration + def publish(self, event: MessageSentEvent) -> None: + pass diff --git a/apps/chat/events/publishers/websocket.py b/apps/chat/events/publishers/websocket.py new file mode 100644 index 0000000..22e2f0f --- /dev/null +++ b/apps/chat/events/publishers/websocket.py @@ -0,0 +1,20 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from apps.chat.events.event import MessageSentEvent + + +class WebSocketPublisher: + def publish(self, event: MessageSentEvent) -> None: + channel_layer = get_channel_layer() + group_name = f"chat_{event.chat_id}" + async_to_sync(channel_layer.group_send)( + group_name, + { + "type": "chat.message", + "post_id": event.post_id, + "sender_id": str(event.sender_id), + "message_type": event.message_type, + **event.payload, + }, + ) diff --git a/apps/chat/integrations/__init__.py b/apps/chat/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc b/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..067b0fd Binary files /dev/null and b/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/integrations/mattermost/__init__.py b/apps/chat/integrations/mattermost/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/integrations/mattermost/__pycache__/__init__.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..70fc240 Binary files /dev/null and b/apps/chat/integrations/mattermost/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000..7b0176a Binary files /dev/null and b/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc differ diff --git a/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000..efcff15 Binary files /dev/null and b/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc differ diff --git a/apps/chat/integrations/mattermost/client.py b/apps/chat/integrations/mattermost/client.py new file mode 100644 index 0000000..b006ff9 --- /dev/null +++ b/apps/chat/integrations/mattermost/client.py @@ -0,0 +1,201 @@ +import secrets +import string +from urllib.parse import urlparse +from uuid import UUID + +from django.conf import settings +from mattermostdriver import Driver +from mattermostdriver.exceptions import ResourceNotFound + +from apps.chat.integrations.mattermost.exceptions import MattermostError + + +class MattermostClient: + """ + Thin adapter around the Mattermost API. + + All calls use a single service-level admin token — no per-user tokens. + Mattermost internal IDs (user IDs, channel IDs, post IDs) are opaque + strings from the perspective of callers; they must never appear in public + API responses or service-layer interfaces. + """ + + def __init__(self): + url = getattr(settings, "MATTERMOST_URL", None) + token = getattr(settings, "MATTERMOST_TOKEN", None) + + if not url: + raise MattermostError("MATTERMOST_URL is not configured") + if not token: + raise MattermostError("MATTERMOST_TOKEN is not configured") + + parsed = urlparse(url) + scheme = parsed.scheme or "https" + hostname = parsed.hostname or url + port = parsed.port or (443 if scheme == "https" else 80) + + self._team_id: str | None = getattr(settings, "MATTERMOST_TEAM_ID", None) or None + + self._driver = Driver( + { + "url": hostname, + "token": token, + "scheme": scheme, + "port": port, + "verify": True, + "debug": False, + } + ) + + # ------------------------------------------------------------------ + # Internal helpers — username and email are deterministic from UUID + # so the same user always maps to the same Mattermost account. + # ------------------------------------------------------------------ + + def _username_for(self, user_id: UUID) -> str: + return f"chat-{UUID(str(user_id)).hex}" + + def _email_for(self, user_id: UUID) -> str: + return f"chat-{UUID(str(user_id)).hex}@mm.internal" + + @staticmethod + def _random_password() -> str: + alphabet = string.ascii_letters + string.digits + "!@#$%" + return "".join(secrets.choice(alphabet) for _ in range(32)) + + # ------------------------------------------------------------------ + # Public API — return only opaque strings, never raw Mattermost dicts + # with identifying fields surfaced beyond this layer. + # ------------------------------------------------------------------ + + def get_or_create_user(self, user_id: UUID) -> str: + """ + Ensure a Mattermost account exists for the given external UUID. + Returns the opaque mattermost_user_id string. + """ + username = self._username_for(user_id) + + try: + user = self._driver.users.get_user_by_username(username) + return user["id"] + except ResourceNotFound: + pass + except Exception as exc: + raise MattermostError(f"User lookup failed: {exc}") from exc + + try: + user = self._driver.users.create_user( + options={ + "username": username, + "email": self._email_for(user_id), + "password": self._random_password(), + } + ) + return user["id"] + except Exception as exc: + # Guard against a concurrent request having created the account + # between our lookup and our create attempt. + try: + user = self._driver.users.get_user_by_username(username) + return user["id"] + except Exception: + pass + raise MattermostError(f"User creation failed: {exc}") from exc + + def create_private_channel(self, mm_user_ids: list[str]) -> str: + """ + Create a new private Mattermost channel and add all members. + Each call always creates a brand-new channel. + Returns the opaque mattermost_channel_id string. + """ + if not self._team_id: + raise MattermostError("MATTERMOST_TEAM_ID is not configured") + + channel_name = f"conv-{secrets.token_hex(8)}" + + try: + channel = self._driver.channels.create_channel( + options={ + "team_id": self._team_id, + "name": channel_name, + "display_name": "Conversation", + "type": "P", # P = private channel (not a DM) + } + ) + except Exception as exc: + raise MattermostError(f"Channel creation failed: {exc}") from exc + + channel_id: str = channel["id"] + + for mm_user_id in mm_user_ids: + try: + self._driver.channels.add_user( + channel_id, options={"user_id": mm_user_id} + ) + except Exception as exc: + raise MattermostError( + f"Adding user {mm_user_id} to channel failed: {exc}" + ) from exc + + return channel_id + + def post_message(self, channel_id: str, message: str) -> str: + """ + Post a message to the given channel using the admin token. + Returns the opaque mattermost_post_id string. + """ + try: + post = self._driver.posts.create_post( + options={ + "channel_id": channel_id, + "message": message, + } + ) + return post["id"] + except Exception as exc: + raise MattermostError(f"Post creation failed: {exc}") from exc + + def get_posts( + self, + channel_id: str, + page: int = 0, + per_page: int = 20, + since: int | None = None, + ) -> list[dict]: + """ + Fetch posts from a channel and return them in chronological order. + + `since` is a Unix timestamp in **milliseconds**. When provided, + `page` is ignored and all posts after that timestamp are returned. + """ + params: dict = {"per_page": per_page} + if since is not None: + params["since"] = since + else: + params["page"] = page + + try: + data = self._driver.posts.get_posts_for_channel(channel_id, params=params) + except Exception as exc: + raise MattermostError(f"Fetching posts failed: {exc}") from exc + + order: list[str] = data.get("order", []) + posts_map: dict = data.get("posts", {}) + + # Mattermost returns newest-first; reverse to get chronological order. + return [posts_map[pid] for pid in reversed(order) if pid in posts_map] + + def get_latest_post_id(self, channel_id: str) -> str | None: + """ + Return the ID of the most recent post in the channel, or None if empty. + Used to derive `has_unread` against a stored last_read_mattermost_post_id. + """ + try: + data = self._driver.posts.get_posts_for_channel( + channel_id, params={"page": 0, "per_page": 1} + ) + except Exception as exc: + raise MattermostError(f"Fetching latest post failed: {exc}") from exc + + order: list[str] = data.get("order", []) + return order[0] if order else None diff --git a/apps/chat/integrations/mattermost/exceptions.py b/apps/chat/integrations/mattermost/exceptions.py new file mode 100644 index 0000000..2237877 --- /dev/null +++ b/apps/chat/integrations/mattermost/exceptions.py @@ -0,0 +1,2 @@ +class MattermostError(Exception): + """Raised when any Mattermost API call fails.""" diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py new file mode 100644 index 0000000..e7cbb68 --- /dev/null +++ b/apps/chat/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.13 on 2026-06-10 12:09 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Conversation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('mattermost_channel_id', models.CharField(db_index=True, max_length=255, unique=True)), + ('type', models.CharField(choices=[('direct', 'Direct'), ('group', 'Group')], default='direct', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'indexes': [models.Index(fields=['type'], name='chat_conver_type_857bd7_idx'), models.Index(fields=['created_at'], name='chat_conver_created_656b50_idx')], + }, + ), + migrations.CreateModel( + name='MattermostAccountMapping', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_id', models.UUIDField(db_index=True, unique=True)), + ('mattermost_user_id', models.CharField(db_index=True, max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'indexes': [models.Index(fields=['mattermost_user_id'], name='chat_matter_matterm_e23c84_idx')], + }, + ), + migrations.CreateModel( + name='ConversationParticipant', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_id', models.UUIDField()), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='chat.conversation')), + ], + options={ + 'indexes': [models.Index(fields=['user_id'], name='chat_conver_user_id_697713_idx'), models.Index(fields=['conversation', 'user_id'], name='chat_conver_convers_59b4dd_idx')], + 'constraints': [models.UniqueConstraint(fields=('conversation', 'user_id'), name='unique_participant_per_conversation')], + }, + ), + migrations.CreateModel( + name='ConversationReadState', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_id', models.UUIDField()), + ('last_read_mattermost_post_id', models.CharField(blank=True, max_length=255, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_states', to='chat.conversation')), + ], + options={ + 'indexes': [models.Index(fields=['user_id'], name='chat_conver_user_id_e7609f_idx'), models.Index(fields=['conversation', 'user_id'], name='chat_conver_convers_9555aa_idx')], + 'constraints': [models.UniqueConstraint(fields=('conversation', 'user_id'), name='unique_read_state_per_user')], + }, + ), + ] diff --git a/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc b/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3d079a8 Binary files /dev/null and b/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/models.py b/apps/chat/models.py deleted file mode 100644 index 71a8362..0000000 --- a/apps/chat/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/apps/chat/models/__init__.py b/apps/chat/models/__init__.py new file mode 100644 index 0000000..d9080a4 --- /dev/null +++ b/apps/chat/models/__init__.py @@ -0,0 +1,12 @@ +from apps.chat.models.conversation import Conversation, ConversationType +from apps.chat.models.mapping import MattermostAccountMapping +from apps.chat.models.participant import ConversationParticipant +from apps.chat.models.read_state import ConversationReadState + +__all__ = [ + "Conversation", + "ConversationType", + "ConversationParticipant", + "MattermostAccountMapping", + "ConversationReadState", +] diff --git a/apps/chat/models/__pycache__/__init__.cpython-313.pyc b/apps/chat/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6c62aef Binary files /dev/null and b/apps/chat/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/models/__pycache__/conversation.cpython-313.pyc b/apps/chat/models/__pycache__/conversation.cpython-313.pyc new file mode 100644 index 0000000..09d1e62 Binary files /dev/null and b/apps/chat/models/__pycache__/conversation.cpython-313.pyc differ diff --git a/apps/chat/models/__pycache__/mapping.cpython-313.pyc b/apps/chat/models/__pycache__/mapping.cpython-313.pyc new file mode 100644 index 0000000..5f50251 Binary files /dev/null and b/apps/chat/models/__pycache__/mapping.cpython-313.pyc differ diff --git a/apps/chat/models/__pycache__/participant.cpython-313.pyc b/apps/chat/models/__pycache__/participant.cpython-313.pyc new file mode 100644 index 0000000..b462e3c Binary files /dev/null and b/apps/chat/models/__pycache__/participant.cpython-313.pyc differ diff --git a/apps/chat/models/__pycache__/read_state.cpython-313.pyc b/apps/chat/models/__pycache__/read_state.cpython-313.pyc new file mode 100644 index 0000000..ae8558f Binary files /dev/null and b/apps/chat/models/__pycache__/read_state.cpython-313.pyc differ diff --git a/apps/chat/models/conversation.py b/apps/chat/models/conversation.py new file mode 100644 index 0000000..d0f5314 --- /dev/null +++ b/apps/chat/models/conversation.py @@ -0,0 +1,30 @@ +import uuid + +from django.db import models + + +class ConversationType(models.TextChoices): + DIRECT = "direct", "Direct" + GROUP = "group", "Group" + + +class Conversation(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # Internal only — never exposed through any public API or serializer + mattermost_channel_id = models.CharField(max_length=255, unique=True, db_index=True) + type = models.CharField( + max_length=20, + choices=ConversationType.choices, + default=ConversationType.DIRECT, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index(fields=["type"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self): + return str(self.id) diff --git a/apps/chat/models/mapping.py b/apps/chat/models/mapping.py new file mode 100644 index 0000000..184a86a --- /dev/null +++ b/apps/chat/models/mapping.py @@ -0,0 +1,18 @@ +import uuid + +from django.db import models + + +class MattermostAccountMapping(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user_id = models.UUIDField(unique=True, db_index=True) + mattermost_user_id = models.CharField(max_length=255, unique=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=["mattermost_user_id"]), + ] + + def __str__(self): + return f"{self.user_id} -> {self.mattermost_user_id}" diff --git a/apps/chat/models/participant.py b/apps/chat/models/participant.py new file mode 100644 index 0000000..528c30d --- /dev/null +++ b/apps/chat/models/participant.py @@ -0,0 +1,31 @@ +import uuid + +from django.db import models + +from apps.chat.models.conversation import Conversation + + +class ConversationParticipant(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation = models.ForeignKey( + Conversation, + related_name="participants", + on_delete=models.CASCADE, + ) + user_id = models.UUIDField() + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["conversation", "user_id"], + name="unique_participant_per_conversation", + ) + ] + indexes = [ + models.Index(fields=["user_id"]), + models.Index(fields=["conversation", "user_id"]), + ] + + def __str__(self): + return f"{self.user_id} in {self.conversation_id}" diff --git a/apps/chat/models/read_state.py b/apps/chat/models/read_state.py new file mode 100644 index 0000000..259c75b --- /dev/null +++ b/apps/chat/models/read_state.py @@ -0,0 +1,32 @@ +import uuid + +from django.db import models + +from apps.chat.models.conversation import Conversation + + +class ConversationReadState(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation = models.ForeignKey( + Conversation, + related_name="read_states", + on_delete=models.CASCADE, + ) + user_id = models.UUIDField() + last_read_mattermost_post_id = models.CharField(max_length=255, null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["conversation", "user_id"], + name="unique_read_state_per_user", + ) + ] + indexes = [ + models.Index(fields=["user_id"]), + models.Index(fields=["conversation", "user_id"]), + ] + + def __str__(self): + return f"{self.user_id} read state for {self.conversation_id}" diff --git a/apps/chat/serializers/__init__.py b/apps/chat/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/serializers/__pycache__/__init__.cpython-313.pyc b/apps/chat/serializers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..830c432 Binary files /dev/null and b/apps/chat/serializers/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc b/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc new file mode 100644 index 0000000..4b61e38 Binary files /dev/null and b/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc differ diff --git a/apps/chat/serializers/__pycache__/messages.cpython-313.pyc b/apps/chat/serializers/__pycache__/messages.cpython-313.pyc new file mode 100644 index 0000000..5e9a526 Binary files /dev/null and b/apps/chat/serializers/__pycache__/messages.cpython-313.pyc differ diff --git a/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc new file mode 100644 index 0000000..643a26c Binary files /dev/null and b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc differ diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py new file mode 100644 index 0000000..8b3f322 --- /dev/null +++ b/apps/chat/serializers/conversations.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from apps.chat.models import Conversation, ConversationParticipant + + +class ConversationSerializer(serializers.ModelSerializer): + participants = serializers.SerializerMethodField() + + class Meta: + model = Conversation + fields = ["id", "type", "created_at", "participants"] + + def get_participants(self, obj): + return list( + ConversationParticipant.objects.filter(conversation_id=obj.id) + .order_by("joined_at") + .values_list("user_id", flat=True) + ) + + +class ConversationListSerializer(serializers.Serializer): + id = serializers.UUIDField() + type = serializers.CharField() + created_at = serializers.DateTimeField() + participants = serializers.ListField(child=serializers.UUIDField()) + has_unread = serializers.BooleanField() + last_message = serializers.DictField(allow_null=True, required=False) diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py new file mode 100644 index 0000000..833568b --- /dev/null +++ b/apps/chat/serializers/messages.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +class MessageSerializer(serializers.Serializer): + post_id = serializers.CharField() + sender_id = serializers.UUIDField(allow_null=True) + message_type = serializers.CharField(default="text") + text = serializers.CharField(allow_null=True, required=False) + url = serializers.CharField(allow_null=True, required=False) + created_at = serializers.DateTimeField(allow_null=True, required=False) diff --git a/apps/chat/serializers/read_state.py b/apps/chat/serializers/read_state.py new file mode 100644 index 0000000..23a5c17 --- /dev/null +++ b/apps/chat/serializers/read_state.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class ReadStateSerializer(serializers.Serializer): + conversation_id = serializers.UUIDField() + user_id = serializers.UUIDField() + has_unread = serializers.BooleanField() diff --git a/apps/chat/services/__init__.py b/apps/chat/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/services/__pycache__/__init__.cpython-313.pyc b/apps/chat/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d2d6c02 Binary files /dev/null and b/apps/chat/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/chat/services/__pycache__/account.cpython-313.pyc b/apps/chat/services/__pycache__/account.cpython-313.pyc new file mode 100644 index 0000000..d1830d0 Binary files /dev/null and b/apps/chat/services/__pycache__/account.cpython-313.pyc differ diff --git a/apps/chat/services/__pycache__/conversation.cpython-313.pyc b/apps/chat/services/__pycache__/conversation.cpython-313.pyc new file mode 100644 index 0000000..bb0f29d Binary files /dev/null and b/apps/chat/services/__pycache__/conversation.cpython-313.pyc differ diff --git a/apps/chat/services/__pycache__/message.cpython-313.pyc b/apps/chat/services/__pycache__/message.cpython-313.pyc new file mode 100644 index 0000000..834ec21 Binary files /dev/null and b/apps/chat/services/__pycache__/message.cpython-313.pyc differ diff --git a/apps/chat/services/__pycache__/read_state.cpython-313.pyc b/apps/chat/services/__pycache__/read_state.cpython-313.pyc new file mode 100644 index 0000000..8d142d8 Binary files /dev/null and b/apps/chat/services/__pycache__/read_state.cpython-313.pyc differ diff --git a/apps/chat/services/__pycache__/storage.cpython-313.pyc b/apps/chat/services/__pycache__/storage.cpython-313.pyc new file mode 100644 index 0000000..1cde293 Binary files /dev/null and b/apps/chat/services/__pycache__/storage.cpython-313.pyc differ diff --git a/apps/chat/services/account.py b/apps/chat/services/account.py new file mode 100644 index 0000000..ea4e885 --- /dev/null +++ b/apps/chat/services/account.py @@ -0,0 +1,7 @@ +from uuid import UUID + + +class AccountService: + def validate_user(self, user_id: UUID) -> bool: + # Future: call external Account Service HTTP API + return True diff --git a/apps/chat/services/conversation.py b/apps/chat/services/conversation.py new file mode 100644 index 0000000..8a0fe75 --- /dev/null +++ b/apps/chat/services/conversation.py @@ -0,0 +1,65 @@ +from uuid import UUID + +from django.db import transaction + +from apps.chat.integrations.mattermost.client import MattermostClient +from apps.chat.models import ( + Conversation, + ConversationParticipant, + MattermostAccountMapping, +) +from apps.chat.services.account import AccountService + + +class ConversationService: + def __init__( + self, + account_service: AccountService | None = None, + mattermost_client: MattermostClient | None = None, + ): + self._account = account_service or AccountService() + self._mm = mattermost_client or MattermostClient() + + def create(self, user_1_id: UUID, user_2_id: UUID) -> Conversation: + if not self._account.validate_user(user_1_id): + raise ValueError(f"User {user_1_id} is not valid") + if not self._account.validate_user(user_2_id): + raise ValueError(f"User {user_2_id} is not valid") + + mm_user_1 = self._mm.get_or_create_user(user_1_id) + mm_user_2 = self._mm.get_or_create_user(user_2_id) + + MattermostAccountMapping.objects.get_or_create( + user_id=user_1_id, + defaults={"mattermost_user_id": mm_user_1}, + ) + MattermostAccountMapping.objects.get_or_create( + user_id=user_2_id, + defaults={"mattermost_user_id": mm_user_2}, + ) + + channel_id = self._mm.create_private_channel([mm_user_1, mm_user_2]) + + with transaction.atomic(): + conversation = Conversation.objects.create(mattermost_channel_id=channel_id) + ConversationParticipant.objects.create( + conversation=conversation, user_id=user_1_id + ) + ConversationParticipant.objects.create( + conversation=conversation, user_id=user_2_id + ) + + return conversation + + def list_for_user( + self, user_id: UUID, page: int, page_size: int + ) -> list[Conversation]: + offset = page * page_size + conversation_ids = ConversationParticipant.objects.filter( + user_id=user_id + ).values_list("conversation_id", flat=True) + return list( + Conversation.objects.filter(id__in=conversation_ids).order_by( + "-created_at" + )[offset : offset + page_size] + ) diff --git a/apps/chat/services/message.py b/apps/chat/services/message.py new file mode 100644 index 0000000..e62fce2 --- /dev/null +++ b/apps/chat/services/message.py @@ -0,0 +1,118 @@ +from datetime import datetime, timezone +from uuid import UUID + +from apps.chat.events.event import MessageSentEvent +from apps.chat.events.publishers.longpoll import LongPollPublisher +from apps.chat.events.publishers.push import PushPublisher +from apps.chat.events.publishers.websocket import WebSocketPublisher +from apps.chat.integrations.mattermost.client import MattermostClient +from apps.chat.models import Conversation, MattermostAccountMapping +from apps.chat.services.account import AccountService +from apps.chat.services.storage import StorageService + + +class MessageService: + def __init__( + self, + account_service: AccountService | None = None, + storage_service: StorageService | None = None, + mattermost_client: MattermostClient | None = None, + publishers: list | None = None, + ): + self._account = account_service or AccountService() + self._storage = storage_service or StorageService() + self._mm = mattermost_client or MattermostClient() + self._publishers = ( + publishers + if publishers is not None + else [WebSocketPublisher(), PushPublisher(), LongPollPublisher()] + ) + + def send( + self, + conversation_id, + sender_id: UUID, + message_type: str, + text: str | None = None, + file=None, + ) -> dict: + if not self._account.validate_user(sender_id): + raise ValueError(f"Sender {sender_id} is not valid") + + file_url: str | None = None + if file is not None: + filename = getattr(file, "name", f"{sender_id}") + file_url = self._storage.upload_file(file, filename) + + if message_type == "text": + mm_message = text or "" + else: + mm_message = file_url or text or "" + + conversation = Conversation.objects.get(id=conversation_id) + post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message) + + event = MessageSentEvent( + chat_id=conversation_id, + post_id=post_id, + sender_id=sender_id, + message_type=message_type, + payload={"text": text, "file_url": file_url}, + ) + for publisher in self._publishers: + publisher.publish(event) + + return { + "post_id": post_id, + "sender_id": sender_id, + "message_type": message_type, + "text": text, + "url": file_url, + "created_at": None, + } + + def list_messages( + self, + conversation_id, + page: int, + per_page: int, + since: int | None = None, + ) -> list[dict]: + conversation = Conversation.objects.get(id=conversation_id) + raw_posts = self._mm.get_posts( + conversation.mattermost_channel_id, + page=page, + per_page=per_page, + since=since, + ) + + mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")} + mappings = { + m.mattermost_user_id: m.user_id + for m in MattermostAccountMapping.objects.filter( + mattermost_user_id__in=mm_user_ids + ) + } if mm_user_ids else {} + + return [self._normalize_post(p, mappings) for p in raw_posts] + + @staticmethod + def _normalize_post(post: dict, mappings: dict) -> dict: + mm_uid = post.get("user_id") + sender_id = mappings.get(mm_uid) + msg = post.get("message", "") + is_url = msg.startswith("http") + created_ms = post.get("create_at") + created_at = ( + datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc) + if created_ms + else None + ) + return { + "post_id": post.get("id"), + "sender_id": sender_id, + "message_type": "file" if is_url else "text", + "text": None if is_url else msg, + "url": msg if is_url else None, + "created_at": created_at, + } diff --git a/apps/chat/services/read_state.py b/apps/chat/services/read_state.py new file mode 100644 index 0000000..679b9c9 --- /dev/null +++ b/apps/chat/services/read_state.py @@ -0,0 +1,48 @@ +from uuid import UUID + +from django.core.cache import cache + +from apps.chat.integrations.mattermost.client import MattermostClient +from apps.chat.models import Conversation, ConversationReadState + +_LATEST_POST_CACHE_TTL = 60 + + +class ReadStateService: + def __init__(self, mattermost_client: MattermostClient | None = None): + self._mm = mattermost_client or MattermostClient() + + def mark_read( + self, conversation_id, user_id: UUID, post_id: str + ) -> ConversationReadState: + read_state, _ = ConversationReadState.objects.update_or_create( + conversation_id=conversation_id, + user_id=user_id, + defaults={"last_read_mattermost_post_id": post_id}, + ) + return read_state + + def has_unread(self, conversation_id, user_id: UUID) -> bool: + try: + read_state = ConversationReadState.objects.get( + conversation_id=conversation_id, user_id=user_id + ) + except ConversationReadState.DoesNotExist: + return True + + last_read = read_state.last_read_mattermost_post_id + + cache_key = f"latest_post:{conversation_id}" + latest_post_id = cache.get(cache_key) + + if latest_post_id is None: + conversation = Conversation.objects.get(id=conversation_id) + latest_post_id = self._mm.get_latest_post_id( + conversation.mattermost_channel_id + ) + cache.set(cache_key, latest_post_id or "", _LATEST_POST_CACHE_TTL) + + if not latest_post_id: + return False + + return latest_post_id != last_read diff --git a/apps/chat/services/realtime.py b/apps/chat/services/realtime.py new file mode 100644 index 0000000..190fe10 --- /dev/null +++ b/apps/chat/services/realtime.py @@ -0,0 +1,12 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + + +class RealtimeService: + def publish_message(self, chat_id, payload: dict) -> None: + channel_layer = get_channel_layer() + group_name = f"chat_{chat_id}" + async_to_sync(channel_layer.group_send)( + group_name, + {"type": "chat.message", **payload}, + ) diff --git a/apps/chat/services/storage.py b/apps/chat/services/storage.py new file mode 100644 index 0000000..eff1738 --- /dev/null +++ b/apps/chat/services/storage.py @@ -0,0 +1,42 @@ +import io +import mimetypes +from uuid import uuid4 + +from django.conf import settings +from minio import Minio + + +class StorageService: + def __init__(self): + endpoint = getattr(settings, "MINIO_ENDPOINT", None) + if not endpoint: + raise ValueError("MINIO_ENDPOINT is not configured") + + secure = getattr(settings, "MINIO_SECURE", False) + self._client = Minio( + endpoint, + access_key=getattr(settings, "MINIO_ACCESS_KEY", None), + secret_key=getattr(settings, "MINIO_SECRET_KEY", None), + secure=secure, + ) + self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat") + scheme = "https" if secure else "http" + self._base_url = f"{scheme}://{endpoint}" + + def upload_file(self, file, filename: str) -> str: + data = file.read() if hasattr(file, "read") else file + size = len(data) + + content_type, _ = mimetypes.guess_type(filename) + if not content_type: + content_type = "application/octet-stream" + + object_name = f"{uuid4().hex}/{filename}" + self._client.put_object( + self._bucket, + object_name, + io.BytesIO(data), + size, + content_type=content_type, + ) + return f"{self._base_url}/{self._bucket}/{object_name}" diff --git a/apps/chat/tests.py b/apps/chat/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/chat/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/chat/tests/__init__.py b/apps/chat/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/tests/test_conversations.py b/apps/chat/tests/test_conversations.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/tests/test_read_state.py b/apps/chat/tests/test_read_state.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/urls.py b/apps/chat/urls.py new file mode 100644 index 0000000..f332565 --- /dev/null +++ b/apps/chat/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("api/", include("apps.chat.api.urls")), +] diff --git a/apps/chat/views.py b/apps/chat/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/apps/chat/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/apps/chat/websocket/__init__.py b/apps/chat/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/websocket/consumers.py b/apps/chat/websocket/consumers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/__pycache__/__init__.cpython-313.pyc b/apps/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..461e6fe Binary files /dev/null and b/apps/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/core/__pycache__/admin.cpython-313.pyc b/apps/core/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..a0add47 Binary files /dev/null and b/apps/core/__pycache__/admin.cpython-313.pyc differ diff --git a/apps/core/__pycache__/apps.cpython-313.pyc b/apps/core/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000..fbf0a81 Binary files /dev/null and b/apps/core/__pycache__/apps.cpython-313.pyc differ diff --git a/apps/core/__pycache__/models.cpython-313.pyc b/apps/core/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..b367e7a Binary files /dev/null and b/apps/core/__pycache__/models.cpython-313.pyc differ diff --git a/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000..6b44a36 Binary files /dev/null and b/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/apps/core/migrations/__pycache__/__init__.cpython-313.pyc b/apps/core/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d0d40d2 Binary files /dev/null and b/apps/core/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..09157ec Binary files /dev/null and b/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..be781c9 Binary files /dev/null and b/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000..12ea8e5 Binary files /dev/null and b/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..14076dd Binary files /dev/null and b/apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000..35bb56f Binary files /dev/null and b/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc new file mode 100644 index 0000000..cedc8f4 Binary files /dev/null and b/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc new file mode 100644 index 0000000..ae77f92 Binary files /dev/null and b/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc differ diff --git a/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..2cf833f Binary files /dev/null and b/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/users/__pycache__/__init__.cpython-313.pyc b/apps/users/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a353a97 Binary files /dev/null and b/apps/users/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/users/__pycache__/admin.cpython-313.pyc b/apps/users/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..ad9d3ce Binary files /dev/null and b/apps/users/__pycache__/admin.cpython-313.pyc differ diff --git a/apps/users/__pycache__/apps.cpython-313.pyc b/apps/users/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000..0070092 Binary files /dev/null and b/apps/users/__pycache__/apps.cpython-313.pyc differ diff --git a/apps/users/__pycache__/models.cpython-313.pyc b/apps/users/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..1616f7f Binary files /dev/null and b/apps/users/__pycache__/models.cpython-313.pyc differ diff --git a/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000..b1eb08b Binary files /dev/null and b/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/apps/users/migrations/__pycache__/__init__.cpython-313.pyc b/apps/users/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d0cbd8b Binary files /dev/null and b/apps/users/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/env.sample b/env.sample index 3a63c12..1dd3033 100644 --- a/env.sample +++ b/env.sample @@ -13,4 +13,14 @@ REDIS_BASE_URL=redis://127.0.0.1:6379/1 MATTERMOST_TOKEN=3fktq5yzw7yp7m4ty7d3nx8gph MATTERMOST_TOKEN_ID=h5h7unnmeigxdjk4yhtywxjkow -MATTERMOST_BASE_PUBLICE_URL=https://chat.addwin.ir \ No newline at end of file +MATTERMOST_BASE_PUBLICE_URL=https://chat.addwin.ir +MATTERMOST_BASE_PUBLIC_URL=https://chat.addwin.ir +MATTERMOST_TEAM_ID= +MATTERMOST_SERVICE_USERNAME=chat-service +MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid +CHAT_LONG_POLL_TIMEOUT_SECONDS=25 +CHAT_LONG_POLL_INTERVAL_SECONDS=1 +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET_CHAT=chat diff --git a/main/__pycache__/__init__.cpython-313.pyc b/main/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fbaf534 Binary files /dev/null and b/main/__pycache__/__init__.cpython-313.pyc differ diff --git a/main/__pycache__/settings.cpython-313.pyc b/main/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..1e0345a Binary files /dev/null and b/main/__pycache__/settings.cpython-313.pyc differ diff --git a/main/__pycache__/urls.cpython-313.pyc b/main/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..d983bd6 Binary files /dev/null and b/main/__pycache__/urls.cpython-313.pyc differ diff --git a/main/asgi.py b/main/asgi.py index 04b4e6d..31b565c 100644 --- a/main/asgi.py +++ b/main/asgi.py @@ -1,16 +1,27 @@ """ -ASGI config for main project. +ASGI config for chat project. It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ import os from django.core.asgi import get_asgi_application +from apps.chat.websocket import websocket_application + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') -application = get_asgi_application() +django_asgi_application = get_asgi_application() + + +async def application(scope, receive, send): + if scope["type"] == "http": + await django_asgi_application(scope, receive, send) + return + + if scope["type"] == "websocket": + await websocket_application(scope, receive, send) + return + + raise ValueError(f"Unsupported ASGI scope type: {scope['type']}") diff --git a/main/other_settings/__pycache__/__init__.cpython-313.pyc b/main/other_settings/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b79ae16 Binary files /dev/null and b/main/other_settings/__pycache__/__init__.cpython-313.pyc differ diff --git a/main/other_settings/__pycache__/logging.cpython-313.pyc b/main/other_settings/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000..a67518b Binary files /dev/null and b/main/other_settings/__pycache__/logging.cpython-313.pyc differ diff --git a/main/settings.py b/main/settings.py index 177a9c5..8e9d500 100644 --- a/main/settings.py +++ b/main/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'apps.core', 'apps.users', 'apps.gooyal_oauth2', + 'apps.chat', ] MIDDLEWARE = [ @@ -135,6 +136,7 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) WSGI_APPLICATION = 'main.wsgi.application' +ASGI_APPLICATION = 'main.asgi.application' # Database # https://docs.djangoproject.com/en/5.1/ref/settings/#databases @@ -207,6 +209,11 @@ if DEBUG == True: else: STATIC_ROOT = BASE_DIR / 'static' +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field @@ -252,4 +259,22 @@ from main.other_settings.logging import get_logging_setting LOGGING = get_logging_setting() NOTIFICATIONS_BASE_PUBLIC_URL = config('NOTIFICATIONS_BASE_PUBLIC_URL', default=None, cast=str) +MATTERMOST_URL = config('MATTERMOST_URL', default=None) MATTERMOST_TOKEN = config('MATTERMOST_TOKEN', default=None, cast=str) +MATTERMOST_TEAM_ID = config('MATTERMOST_TEAM_ID', default=None, cast=str) +# Legacy aliases kept for backward compatibility +MATTERMOST_BASE_URL = config( + 'MATTERMOST_BASE_PUBLIC_URL', + default=config('MATTERMOST_BASE_PUBLICE_URL', default=None, cast=str), + cast=str, +) +MATTERMOST_SERVICE_USERNAME = config('MATTERMOST_SERVICE_USERNAME', default='chat-service', cast=str) +MATTERMOST_SERVICE_EMAIL = config('MATTERMOST_SERVICE_EMAIL', default='chat-service@local.invalid', cast=str) +CHAT_LONG_POLL_TIMEOUT_SECONDS = config('CHAT_LONG_POLL_TIMEOUT_SECONDS', default=25, cast=int) +CHAT_LONG_POLL_INTERVAL_SECONDS = config('CHAT_LONG_POLL_INTERVAL_SECONDS', default=1, cast=int) +MINIO_ENDPOINT = config('MINIO_ENDPOINT', default=None) +MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None) +MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None) +MINIO_BUCKET_CHAT = config('MINIO_BUCKET_CHAT', default='chat') +GDAL_LIBRARY_PATH = config('GDAL_LIBRARY_PATH', default=None) +GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None) diff --git a/main/urls.py b/main/urls.py index 4cdddc4..887d94e 100644 --- a/main/urls.py +++ b/main/urls.py @@ -36,6 +36,7 @@ urlpatterns = [ path('admin/', admin.site.urls), # path('', include('apps.pg.urls')), # path('users/', include('apps.users.urls')), + path('', include('apps.chat.urls')), path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] diff --git a/requirements.in b/requirements.in index 1d1057a..fb41f7e 100644 --- a/requirements.in +++ b/requirements.in @@ -20,4 +20,7 @@ django-redis xlsxwriter xlrd django_admin_logs -mattermostdriver \ No newline at end of file +mattermostdriver +channels +channels-redis +minio \ No newline at end of file diff --git a/utils/__pycache__/exceptions.cpython-313.pyc b/utils/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000..6a7b16a Binary files /dev/null and b/utils/__pycache__/exceptions.cpython-313.pyc differ