From ad2bcf4b2150ae78b4ea8fffb8bfde79d8171b16 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 8 Nov 2025 10:54:30 +0100 Subject: [PATCH 01/12] Redis integration --- Dockerfile | 2 +- Fools_Arena/settings.py | 8 ++++++-- README.md | 3 ++- docker-compose.yml | 7 ++++++- requirements.txt | Bin 554 -> 1506 bytes 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index a9de880..c822b15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "Fools_Arena.asgi:application", "--bind", "0.0.0.0:8000"] +CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "Fools_Arena.asgi:application"] diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index d6bc835..e1d761c 100644 --- a/Fools_Arena/settings.py +++ b/Fools_Arena/settings.py @@ -52,10 +52,14 @@ CHANNEL_LAYERS = { "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer" - } + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("redis", 6379)], + }, + }, } + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/README.md b/README.md index a55fa2a..d908f7a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ docker compose down --- ## 🚀 Stack -- Django, REST, Channels +- Django, REST, Channels +- Redis - PostgreSQL - Docker - GitFlow diff --git a/docker-compose.yml b/docker-compose.yml index c08cee8..ed52fd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: web: build: . - command: gunicorn -k uvicorn.workers.UvicornWorker Fools_Arena.asgi:application --bind 0.0.0.0:8000 + command: daphne -b 0.0.0.0 -p 8000 Fools_Arena.asgi:application volumes: - .:/app ports: @@ -10,6 +10,11 @@ services: - .env depends_on: - db + - redis + + redis: + image: redis:alpine + restart: always db: image: postgres:17 diff --git a/requirements.txt b/requirements.txt index 454e5dc976e1dd6083cc6089df3dde676432eb7b..9325eedb4f7ac790307e2eaba295ce46eafbd55c 100644 GIT binary patch literal 1506 zcmZ9MPjAyu5XAS4#7AjGobvC$0jbAIAi+H{w&NID+aykD`r(23?Y*(-}!VQ&?=(fcMvkBScVLwP&v7db0s zWQ@-iSr3kOiL50-c2pEV4bsrb0w6{ zD*NTNJ|v_Z8x$RMzBJgv1|}e6o`aYi)HR$YWuFq~;1k&>R|(~0qwCW;;;2p{`$lNd zFTXfUc5QCx)EHg(Y0AMYl54Te+>+*ZEyt_2-u z_sE*n-zj(HRr!Wn*%x{lRd4Tl_DRn|Im`2%)j|#>qkh!x^c95~>D=f)dft1T!n=Pj zl$by=@eAFVkTzw-1cYx7>be!1^6V=tcmpFxN}u)qjDeDYFsk}d-`nI6XXjo=;h&tt zsEop{H1Y5%)j{?@wuZUK{ZZf9t$v~2to$;%kphEMI*I}AEoUyXr#3yXhbT@C6HsUi zsPRLo2##H@ebnb7}WzoqA_SynZ=1@*gj zgBt=)^+JnzCMJ85=AC$l-b7|SsXQvmG;M_Ol)5|k3GN6^g(gePXI0}?jh!geXWXjc k^-=1?wC9aPhqZ+w@O~B__U_ti!+x*5VDjVcz_D4o|FhBBG5`Po delta 87 zcmaFFy^4kR|Gxr;N`?}KREA=PlF8aE^76b4TnvdoUOGc2LlICsjlmWOjT!V9EP>c) n;z7}gTSO*)vzfe%aoXe;Oe&MLm`x@pv1m-b#iBI1h}8)I4igxo From a3c3d754071d39a008a0580669809ef2609b1d59 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 8 Nov 2025 20:25:02 +0100 Subject: [PATCH 02/12] Simple web-socket chat --- Fools_Arena/urls.py | 6 +++--- chat/consumers.py | 44 ++++++++++++++++++++++++++++++++++++++++ chat/routing.py | 6 ++++-- chat/templates/chat.html | 24 ++++++++++++++++++++++ chat/urls.py | 6 ++++++ chat/views.py | 3 ++- 6 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 chat/consumers.py create mode 100644 chat/templates/chat.html create mode 100644 chat/urls.py diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index f8e673e..fd2a248 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -25,10 +25,10 @@ urlpatterns = [ path("admin/", admin.site.urls), # UI - path('accounts/', include('accounts.urls')), - + path("accounts/", include("accounts.urls")), + path("chat/", include("chat.urls")), # API - path('api/accounts/', include('accounts.api_urls')), + path("api/accounts/", include("accounts.api_urls")), ] diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..4f04064 --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,44 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer + +class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.room_name = self.scope["url_route"]["kwargs"]["room_name"] + self.room_group_name = f"chat_{self.room_name}" + + # Подключаемся к группе (группы = комнаты) + await self.channel_layer.group_add( + self.room_group_name, + self.channel_name + ) + + await self.accept() # принимаем соединение + + async def disconnect(self, close_code): + await self.channel_layer.group_discard( + self.room_group_name, + self.channel_name + ) + + # Принимаем сообщение от WebSocket + async def receive(self, text_data): + data = json.loads(text_data) + message = data["message"] + + # Рассылаем сообщение всем в группе + await self.channel_layer.group_send( + self.room_group_name, + { + "type": "chat_message", + "message": message + } + ) + + # Обработка сообщения от группы + async def chat_message(self, event): + message = event["message"] + + # Отправляем обратно клиенту + await self.send(text_data=json.dumps({ + "message": message + })) diff --git a/chat/routing.py b/chat/routing.py index 9338e54..e9ab611 100644 --- a/chat/routing.py +++ b/chat/routing.py @@ -1,5 +1,7 @@ -from django.urls import path +from django.urls import re_path +from . import consumers -websocket_urlpatterns = [ +websocket_urlpatterns = [ + re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), ] diff --git a/chat/templates/chat.html b/chat/templates/chat.html new file mode 100644 index 0000000..a810fbe --- /dev/null +++ b/chat/templates/chat.html @@ -0,0 +1,24 @@ +
+ +
+ + +
+ + diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..d38f4a6 --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import chat_view + +urlpatterns = [ + path('chat/', chat_view, name='chat'), +] diff --git a/chat/views.py b/chat/views.py index 91ea44a..d7a84fc 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,3 +1,4 @@ from django.shortcuts import render -# Create your views here. +def chat_view(request): + return render(request, "chat.html") From 5286f77c82eb4d7ae25621ae1639db38126c84b3 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Tue, 11 Nov 2025 20:40:56 +0100 Subject: [PATCH 03/12] Chat and ChatParticipant models --- Fools_Arena/asgi.py | 5 +- chat/migrations/0004_chat_chatparticipant.py | 49 ++++ chat/models.py | 233 +++++++++++++++++-- game/models.py | 34 +-- 4 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 chat/migrations/0004_chat_chatparticipant.py diff --git a/Fools_Arena/asgi.py b/Fools_Arena/asgi.py index 36517c8..5b3ae86 100644 --- a/Fools_Arena/asgi.py +++ b/Fools_Arena/asgi.py @@ -1,7 +1,7 @@ """ ASGI config for Fools_Arena project. -It exposes the ASGI callable as a module-level variable named ``application``. +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.2/howto/deployment/asgi/ @@ -9,6 +9,7 @@ import os +from channels.auth import AuthMiddlewareStack from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter @@ -19,5 +20,5 @@ application = ProtocolTypeRouter({ "http": get_asgi_application(), - "websocket": websocket_application, + "websocket": AuthMiddlewareStack(websocket_application), }) \ No newline at end of file diff --git a/chat/migrations/0004_chat_chatparticipant.py b/chat/migrations/0004_chat_chatparticipant.py new file mode 100644 index 0000000..d97d807 --- /dev/null +++ b/chat/migrations/0004_chat_chatparticipant.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.6 on 2025-11-11 19:35 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0003_alter_message_lobby_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Chat', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=100)), + ('description', models.TextField(blank=True)), + ('is_group', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + 'verbose_name': 'Chat', + 'verbose_name_plural': 'Chats', + 'indexes': [models.Index(fields=['is_group', 'created_at'], name='chat_chat_is_grou_dbc7c3_idx'), models.Index(fields=['name'], name='chat_chat_name_053172_idx')], + }, + ), + migrations.CreateModel( + name='ChatParticipant', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=20)), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chat.chat')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Chat Participant', + 'verbose_name_plural': 'Chat Participants', + 'indexes': [models.Index(fields=['chat', 'user'], name='chat_chatpa_chat_id_069740_idx'), models.Index(fields=['role'], name='chat_chatpa_role_282c45_idx'), models.Index(fields=['joined_at'], name='chat_chatpa_joined__b83314_idx')], + 'unique_together': {('chat', 'user')}, + }, + ), + ] diff --git a/chat/models.py b/chat/models.py index ecd609e..08e8bd9 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,11 +1,216 @@ """Chat models for the Durak card game application. -This module contains all the Django models used in the chat system for -the online multiplayer Durak card game. +This module defines the core models for managing chat rooms and their participants +in the Durak online multiplayer system. It includes models for representing chats, +user roles, and membership management. + +Classes: + Chat: Represents a chat room (group or private) used for communication. + ChatParticipant: Defines user participation in a chat, including their role + and join date. + Message: Represents a single message sent between users in a chat. """ import uuid from django.db import models +from django.utils import timezone +from django.contrib.auth import get_user_model +from django.db import transaction + +User = get_user_model() + + +class Chat(models.Model): + """Represents a chat room, either group or private. + + Each chat can contain multiple participants and messages. This model is used + to logically separate different communication contexts (e.g., private DM or + group lobby discussion). + + Attributes: + id (UUIDField): Primary key (UUID4) for unique chat identification. + name (CharField): Optional name of the chat room (e.g., "Lobby 1"). + description (TextField): Optional text describing the chat purpose. + is_group (BooleanField): Defines if chat is a group or private conversation. + created_at (DateTimeField): Timestamp for chat creation. + + Example: + chat = Chat.objects.create( + name="General Lobby", + description="Main lobby for all players", + is_group=True + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=100, blank=True) + description = models.TextField(blank=True) + is_group = models.BooleanField(default=False) + created_at = models.DateTimeField(default=timezone.now) + + class Meta: + verbose_name = 'Chat' + verbose_name_plural = 'Chats' + indexes = [ + models.Index(fields=["is_group", "created_at"]), + models.Index(fields=["name"]), + ] + + def __str__(self): + """Return a readable representation of the chat. + + Returns: + str: Chat name or fallback to ID. + """ + return self.name or f"Chat {self.id}" + + def get_participants(self): + """Return a QuerySet of users participating in this chat. + + Returns: + QuerySet[User]: Users who are members of this chat. + """ + return User.objects.filter(chat_participations__chat=self).select_related().distinct() + + def has_participant(self, user): + """Check if a user is a member of the chat. + + Args: + user (User): The user to check. + + Returns: + bool: True if user participates in this chat. + """ + return ChatParticipant.objects.filter(chat=self, user=user).exists() + + def add_participant(self, user, role="member"): + """Add a user to the chat with an optional role. + + If the user already exists in the chat, do not create a duplicate; optionally + update their role if it differs. + + Args: + user (User): The user to add. + role (str): Role in the chat ("owner", "admin", or "member"). + Returns: + ChatParticipant: The participant instance and a boolean created flag. + """ + + with transaction.atomic(): + participant, created = ChatParticipant.objects.get_or_create( + chat=self, + user=user, + defaults={"role": role} + ) + if not created and participant.role != role: + participant.role = role + participant.save(update_fields=["role"]) + + return participant, created + + def remove_participant(self, user): + """Remove a user from the chat. + + Args: + user (User): The user to remove. + + Returns: + int: Number of deleted rows (0 or 1). + """ + return ChatParticipant.objects.filter(chat=self, user=user).delete()[0] + + def get_owners(self): + """Return a QuerySet of ChatParticipant objects with role 'owner'. + + Returns: + QuerySet[ChatParticipant]: Participant records for owners (selects related user). + """ + return ChatParticipant.objects.filter(chat=self, role="owner").select_related("user") + + def get_admins(self): + """Return a QuerySet of ChatParticipant objects with role 'admin'. + + Returns: + QuerySet[ChatParticipant]: Participant records for admins (selects related user). + """ + return ChatParticipant.objects.filter(chat=self, role="admin").select_related("user") + + +class ChatParticipant(models.Model): + """Defines a user's participation and role within a chat. + + Each record represents one user's membership in one chat. Roles determine + their permissions (e.g., ownership, admin privileges, or regular member). + + Attributes: + id (UUIDField): Primary key (UUID4) for unique participant record. + chat (ForeignKey): The chat this user belongs to. + user (ForeignKey): The user participating in the chat. + role (CharField): User's role in the chat ("owner", "admin", "member"). + joined_at (DateTimeField): Timestamp of when the user joined the chat. + + Example: + participant = ChatParticipant.objects.create( + chat=chat, + user=user, + role="admin" + ) + """ + ROLE_CHOICES = [ + ('owner', 'Owner'), + ('admin', 'Admin'), + ('member', 'Member'), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + chat = models.ForeignKey(Chat, on_delete=models.CASCADE) + user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member') + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Chat Participant" + verbose_name_plural = "Chat Participants" + unique_together = ("chat", "user") + indexes = [ + models.Index(fields=["chat", "user"]), + models.Index(fields=["role"]), + models.Index(fields=["joined_at"]), + ] + + def __str__(self): + """Return readable representation of the participant. + + Returns: + str: User’s username and chat name. + """ + return f"User {self.user.username} in {self.chat} chat" + + def is_owner(self): + """Check if the participant is the chat owner. + + Returns: + bool: True if participant has 'owner' role. + """ + return self.role == "owner" + + def is_admin(self): + """Check if the participant has admin privileges. + + Returns: + bool: True if role is 'admin' or 'owner'. + """ + return self.role in ("admin", "owner") + + def promote(self): + """Promote participant to admin if not already.""" + if self.role == "member": + self.role = "admin" + self.save(update_fields=["role"]) + + def demote(self): + """Demote participant to member if currently admin.""" + if self.role == "admin": + self.role = "member" + self.save(update_fields=["role"]) class Message(models.Model): @@ -42,12 +247,12 @@ class Message(models.Model): content="Private message" ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) sender = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='sent_messages') receiver = models.ForeignKey('accounts.User', on_delete=models.CASCADE, null=True, blank=True, related_name='received_messages') - lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True, + lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True, related_name='messages') content = models.TextField() sent_at = models.DateTimeField(auto_now_add=True) @@ -60,7 +265,7 @@ def __str__(self): """ preview = self.content[:50] + "..." if len(self.content) > 50 else self.content return f"{self.sender.username}: {preview}" - + def is_private(self): """Check if this is a private message between users. @@ -68,7 +273,7 @@ def is_private(self): bool: True if message has a receiver (private), False if lobby message. """ return self.receiver is not None - + def is_lobby_message(self): """Check if this is a lobby/group message. @@ -76,7 +281,7 @@ def is_lobby_message(self): bool: True if message belongs to a lobby, False if private message. """ return self.lobby is not None - + def get_chat_context(self): """Get the context (lobby or private chat) for this message. @@ -96,7 +301,7 @@ def get_chat_context(self): 'context_name': f"Private chat with {self.receiver.username}" } return {'type': 'unknown', 'context': None, 'context_name': 'Unknown'} - + @classmethod def get_lobby_messages(cls, lobby, limit=50): """Get recent messages for a specific lobby. @@ -109,7 +314,7 @@ def get_lobby_messages(cls, lobby, limit=50): QuerySet: Recent messages in the lobby. """ return cls.objects.filter(lobby=lobby).order_by('-sent_at')[:limit] - + @classmethod def get_private_conversation(cls, user1, user2, limit=50): """Get recent private messages between two users. @@ -123,11 +328,11 @@ def get_private_conversation(cls, user1, user2, limit=50): QuerySet: Recent messages between the users. """ return cls.objects.filter( - models.Q(sender=user1, receiver=user2) | + models.Q(sender=user1, receiver=user2) | models.Q(sender=user2, receiver=user1), lobby__isnull=True ).order_by('-sent_at')[:limit] - + def clean(self): """Validate that message has either lobby or receiver, but not both. @@ -135,12 +340,12 @@ def clean(self): ValidationError: If both lobby and receiver are set, or if neither is set. """ from django.core.exceptions import ValidationError - + if self.lobby and self.receiver: raise ValidationError("Message cannot have both lobby and receiver.") if not self.lobby and not self.receiver: raise ValidationError("Message must have either lobby or receiver.") - + def save(self, *args, **kwargs): """Override save to ensure message validation. @@ -150,7 +355,7 @@ def save(self, *args, **kwargs): """ self.clean() super().save(*args, **kwargs) - + class Meta: verbose_name = 'Message' verbose_name_plural = 'Messages' diff --git a/game/models.py b/game/models.py index 5d6d321..e4345de 100644 --- a/game/models.py +++ b/game/models.py @@ -1207,12 +1207,12 @@ class Turn(models.Model): # Get the next turn number next_turn = Turn.objects.filter(game=game).count() + 1 """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='turns') player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) turn_number = models.IntegerField() - + def __str__(self): """Return string representation of the turn. @@ -1220,7 +1220,7 @@ def __str__(self): str: Turn number and player information. """ return f"Turn {self.turn_number}: {self.player.username} in {self.game}" - + def get_moves(self): """Get all moves made during this turn. @@ -1228,7 +1228,7 @@ def get_moves(self): QuerySet: Move objects associated with this turn. """ return self.moves.all().order_by('created_at') - + def is_complete(self): """Check if this turn has been completed (has moves). @@ -1236,7 +1236,7 @@ def is_complete(self): bool: True if turn has associated moves, False otherwise. """ return self.moves.exists() - + @classmethod def get_current_turn(cls, game): """Get the most recent turn for a game. @@ -1248,7 +1248,7 @@ def get_current_turn(cls, game): Turn: The turn with the highest turn_number, or None if no turns exist. """ return cls.objects.filter(game=game).order_by('-turn_number').first() - + @classmethod def create_next_turn(cls, game, player): """Create the next turn in sequence for a game. @@ -1266,7 +1266,7 @@ def create_next_turn(cls, game, player): player=player, turn_number=next_number ) - + class Meta: verbose_name = 'Turn' verbose_name_plural = 'Turns' @@ -1308,19 +1308,19 @@ class Move(models.Model): action_type='defend' ) """ - + ACTION_CHOICES = [ ('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup'), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) turn = models.ForeignKey(Turn, on_delete=models.CASCADE, related_name='moves') table_card = models.ForeignKey(TableCard, on_delete=models.CASCADE) action_type = models.CharField(max_length=10, choices=ACTION_CHOICES) created_at = models.DateTimeField(auto_now_add=True) - + def __str__(self): """Return string representation of the move. @@ -1328,7 +1328,7 @@ def __str__(self): str: Action type and card information. """ return f"{self.action_type.title()} by {self.turn.player.username}: {self.table_card}" - + def get_player(self): """Get the player who made this move. @@ -1336,7 +1336,7 @@ def get_player(self): User: The user associated with the turn that contains this move. """ return self.turn.player - + def is_attack(self): """Check if this move is an attack action. @@ -1344,7 +1344,7 @@ def is_attack(self): bool: True if action_type is 'attack', False otherwise. """ return self.action_type == 'attack' - + def is_defense(self): """Check if this move is a defense action. @@ -1352,7 +1352,7 @@ def is_defense(self): bool: True if action_type is 'defend', False otherwise. """ return self.action_type == 'defend' - + def is_pickup(self): """Check if this move is a pickup action. @@ -1360,7 +1360,7 @@ def is_pickup(self): bool: True if action_type is 'pickup', False otherwise. """ return self.action_type == 'pickup' - + @classmethod def get_game_moves(cls, game): """Get all moves for a specific game ordered by time. @@ -1372,7 +1372,7 @@ def get_game_moves(cls, game): QuerySet: Move objects for the game ordered by creation time. """ return cls.objects.filter(turn__game=game).order_by('created_at') - + @classmethod def get_player_moves(cls, game, player): """Get all moves made by a specific player in a game. @@ -1385,7 +1385,7 @@ def get_player_moves(cls, game, player): QuerySet: Move objects made by the player in the game. """ return cls.objects.filter(turn__game=game, turn__player=player).order_by('created_at') - + class Meta: verbose_name = 'Move' verbose_name_plural = 'Moves' From 70cddcc6bd29d5c2b5e82e3139677c72da28151c Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Tue, 11 Nov 2025 21:04:58 +0100 Subject: [PATCH 04/12] Test for Chat and ChatParticipant models --- .../0005_alter_chatparticipant_user.py | 21 ++++ chat/models.py | 2 +- chat/tests/test_chat.py | 117 ++++++++++++++++++ .../tests/{test_models.py => test_message.py} | 0 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 chat/migrations/0005_alter_chatparticipant_user.py create mode 100644 chat/tests/test_chat.py rename chat/tests/{test_models.py => test_message.py} (100%) diff --git a/chat/migrations/0005_alter_chatparticipant_user.py b/chat/migrations/0005_alter_chatparticipant_user.py new file mode 100644 index 0000000..e900434 --- /dev/null +++ b/chat/migrations/0005_alter_chatparticipant_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.6 on 2025-11-11 20:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0004_chat_chatparticipant'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='chatparticipant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_participations', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/chat/models.py b/chat/models.py index 08e8bd9..6458580 100644 --- a/chat/models.py +++ b/chat/models.py @@ -162,7 +162,7 @@ class ChatParticipant(models.Model): ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) chat = models.ForeignKey(Chat, on_delete=models.CASCADE) - user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + user = models.ForeignKey('accounts.User', related_name='chat_participations', on_delete=models.CASCADE) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member') joined_at = models.DateTimeField(auto_now_add=True) diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py new file mode 100644 index 0000000..d2cca7d --- /dev/null +++ b/chat/tests/test_chat.py @@ -0,0 +1,117 @@ +"""Tests for the Chat and ChatParticipant models in the chat app.""" + +import pytest +from django.contrib.auth import get_user_model + +from chat.models import Chat, ChatParticipant + +User = get_user_model() + + +@pytest.mark.django_db +class TestChatModel: + """Tests for the Chat model and its participant management.""" + + def test_create_chat(self): + """Test creating a chat instance.""" + chat = Chat.objects.create(name="Test Chat", is_group=True) + assert chat.name == "Test Chat" + assert chat.is_group is True + assert chat.get_participants().count() == 0 + + def test_add_participant(self, test_user): + """Test adding a user to a chat.""" + chat = Chat.objects.create(name="Test Chat") + participant, created = chat.add_participant(test_user, role="admin") + + assert created is True + assert participant.user == test_user + assert participant.role == "admin" + assert chat.has_participant(test_user) is True + + def test_add_existing_participant_updates_role(self, test_user): + """Test that adding an existing participant updates their role.""" + chat = Chat.objects.create(name="Another Chat") + chat.add_participant(test_user, role="member") + participant, created = chat.add_participant(test_user, role="admin") + + assert created is False + assert participant.role == "admin" + + def test_remove_participant(self, test_user): + """Test removing a participant from a chat.""" + chat = Chat.objects.create(name="Chat Remove") + chat.add_participant(test_user) + deleted_count = chat.remove_participant(test_user) + + assert deleted_count == 1 + assert chat.has_participant(test_user) is False + + def test_get_owners_and_admins(self, user_factory): + """Test retrieving owners and admins from a chat.""" + owner = user_factory(username="owner") + admin = user_factory(username="admin") + member = user_factory(username="member") + chat = Chat.objects.create(name="Roles Chat") + + chat.add_participant(owner, role="owner") + chat.add_participant(admin, role="admin") + chat.add_participant(member, role="member") + + owners = chat.get_owners() + admins = chat.get_admins() + + assert owners.count() == 1 + assert owners.first().user == owner + assert admins.count() == 1 + assert admins.first().user == admin + + +@pytest.mark.django_db +class TestChatParticipantModel: + """Tests for ChatParticipant model methods like role checks and promotion/demotion.""" + + def test_is_owner_and_is_admin(self, test_user, second_user): + """Test role checks for owner, admin, and member.""" + chat = Chat.objects.create(name="Role Check Chat") + owner = chat.add_participant(test_user, role="owner")[0] + admin = chat.add_participant(second_user, role="admin")[0] + + assert owner.is_owner() is True + assert owner.is_admin() is True + assert admin.is_owner() is False + assert admin.is_admin() is True + + # Test member + member_user = get_user_model().objects.create_user(username="member") + member = chat.add_participant(member_user, role="member")[0] + assert member.is_owner() is False + assert member.is_admin() is False + + def test_promote_and_demote(self, test_user): + """Test promoting a member to admin and demoting an admin.""" + chat = Chat.objects.create(name="Promotion Chat") + participant = chat.add_participant(test_user, role="member")[0] + + # Promote member + participant.promote() + participant.refresh_from_db() + assert participant.role == "admin" + + # Demote admin + participant.demote() + participant.refresh_from_db() + assert participant.role == "member" + + def test_promote_owner_does_nothing(self, test_user): + """Owner role should not be changed by promote/demote.""" + chat = Chat.objects.create(name="Owner Chat") + participant = chat.add_participant(test_user, role="owner")[0] + + participant.promote() + participant.refresh_from_db() + assert participant.role == "owner" + + participant.demote() + participant.refresh_from_db() + assert participant.role == "owner" diff --git a/chat/tests/test_models.py b/chat/tests/test_message.py similarity index 100% rename from chat/tests/test_models.py rename to chat/tests/test_message.py From 73d42b88c91f5ecb1652a1eb9361ba6b4e870a5c Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Fri, 14 Nov 2025 20:25:53 +0100 Subject: [PATCH 05/12] Added Block model Accounts app: Block model representing if one player was blocked by another, which must lead to inability of sending a message to each other, or connect to lobby where owner is one of participants in relation --- accounts/models.py | 214 ++++++++++++++++++++++++++++----------------- 1 file changed, 136 insertions(+), 78 deletions(-) diff --git a/accounts/models.py b/accounts/models.py index 00838a5..e3dfa12 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,7 +1,8 @@ -"""Accounts models for the Durak card game application. +"""Account models for the Durak online multiplayer card game. -This module contains all the Django models used in the account system for -the online multiplayer Durak card game. +This module defines the User and Block models used for authentication, +player identity management, and handling of user-to-user blocking within +the Durak multiplayer application. """ import uuid @@ -10,75 +11,80 @@ class User(AbstractUser): - """Extended User model for the Durak card game application. - - This model extends Django's AbstractUser to include additional fields - specific to the game functionality such as avatar and creation timestamp. - Uses UUID as primary key for better security and scalability. - + """Extended user model for the Durak card game application. + + This model extends Django's ``AbstractUser`` to support additional + game-specific fields and convenience methods. A UUID is used as a + primary key to improve security, avoid predictable identifiers, and + support distributed systems. + Attributes: - id (UUIDField): Primary key using UUID4 instead of sequential integers. - avatar_url (URLField, optional): URL to user's avatar image. - created_at (DateTimeField): Timestamp when the account was created. - - Inherits from AbstractUser: - username, email, password, first_name, last_name, is_active, - is_staff, is_superuser, date_joined, last_login - - Related Objects: - sent_messages: Messages sent by this user (reverse FK from Message.sender) - received_messages: Private messages received by this user (reverse FK from Message.receiver) - lobby_set: Game lobbies owned by this user (reverse FK from Lobby.owner) - lobbyplayer_set: Lobby memberships (reverse FK from LobbyPlayer.user) - gameplayer_set: Game participations (reverse FK from GamePlayer.user) - playerhand_set: Cards in player's hands (reverse FK from PlayerHand.player) - turn_set: Turns taken by this player (reverse FK from Turn.player) - + id (UUIDField): Primary key using UUID4. + avatar_url (URLField): Optional URL to the user's avatar image. + created_at (DateTimeField): Timestamp of when the user account was created. + + Inherited Attributes from ``AbstractUser``: + username, email, password, first_name, last_name, + is_active, is_staff, is_superuser, + date_joined, last_login + + Reverse Relations: + sent_messages (QuerySet[Message]): Messages sent by the user. + received_messages (QuerySet[Message]): Private messages received by the user. + lobby_set (QuerySet[Lobby]): Lobbies created by the user. + lobbyplayer_set (QuerySet[LobbyPlayer]): Lobby participation records. + gameplayer_set (QuerySet[GamePlayer]): Game participation records. + playerhand_set (QuerySet[PlayerHand]): Cards owned by the user in a match. + turn_set (QuerySet[Turn]): Turns made by the user. + Example: - # Create a new user user = User.objects.create_user( - username='player1', - email='player1@example.com', - password='secure_password' + username="player1", + email="player1@example.com", + password="secure_password" ) - user.avatar_url = 'https://example.com/avatar.jpg' + user.avatar_url = "https://example.com/avatar.jpg" user.save() """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) avatar_url = models.URLField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - """Return string representation of the user. - + """Return the string representation of the user. + Returns: str: The username of the user. """ return self.username - + def get_full_display_name(self): - """Get user's display name with fallback to username. - + """Return the user's display name with fallback to username. + Returns: - str: Full name if available, otherwise username. + str: The user's full name if available, otherwise username. """ full_name = self.get_full_name() return full_name if full_name else self.username - + def has_avatar(self): - """Check if user has an avatar set. - + """Check whether the user has an avatar set. + Returns: - bool: True if avatar_url is set, False otherwise. + bool: True if ``avatar_url`` is defined, otherwise False. """ return bool(self.avatar_url) - + def get_active_lobby(self): - """Get the lobby this user is currently participating in. - + """Return the lobby in which the user is currently active. + + A user is considered active if their lobby status is one of: + ``waiting``, ``ready``, or ``playing``. + Returns: - Lobby: The lobby where user has active status, or None. + Lobby | None: The active lobby instance, or None if the user + is not currently in any lobby. """ from game.models import LobbyPlayer try: @@ -89,12 +95,13 @@ def get_active_lobby(self): return lobby_player.lobby except LobbyPlayer.DoesNotExist: return None - + def get_current_game(self): - """Get the game this user is currently playing. - + """Return the game the user is currently playing. + Returns: - Game: The active game the user is participating in, or None. + Game | None: The active game instance, or None if the user + is not participating in an in-progress match. """ from game.models import GamePlayer try: @@ -105,35 +112,32 @@ def get_current_game(self): return game_player.game except GamePlayer.DoesNotExist: return None - + def can_join_lobby(self, lobby): - """Check if this user can join a specific lobby. - + """Determine whether the user is allowed to join the specified lobby. + Args: - lobby (Lobby): The lobby to check joining permissions for. - + lobby (Lobby): The lobby to evaluate. + Returns: - bool: True if user can join, False otherwise. + bool: True if the user is allowed to join the lobby, otherwise False. """ - # User cannot join if already in a lobby if self.get_active_lobby(): return False - - # Cannot join if lobby is full + if lobby.is_full(): return False - - # Cannot join closed lobbies + if lobby.status == 'closed': return False - + return True - + def leave_current_lobby(self): - """Remove this user from their current lobby if they're in one. - + """Remove the user from their active lobby if they are currently in one. + Returns: - bool: True if user was in a lobby and left, False if not in a lobby. + bool: True if the user left a lobby, False if they were not part of any lobby. """ from game.models import LobbyPlayer try: @@ -145,21 +149,30 @@ def leave_current_lobby(self): return True except LobbyPlayer.DoesNotExist: return False - + def get_game_statistics(self): - """Get basic game statistics for this user. - + """Return basic gameplay statistics for the user. + + The statistics include: + - total number of finished games + - number of wins + - number of losses + - win rate percentage + Returns: - dict: Dictionary containing games played, won, and win rate. + dict: A dictionary containing: + total_games (int) + games_won (int) + games_lost (int) + win_rate (float) """ from game.models import Game, GamePlayer - - # Get all finished games this user participated in + finished_games = Game.objects.filter( players__user=self, status='finished' ) - + total_games = finished_games.count() if total_games == 0: return { @@ -168,20 +181,65 @@ def get_game_statistics(self): 'games_lost': 0, 'win_rate': 0.0 } - - # Count losses (games where this user is the loser) + games_lost = finished_games.filter(loser=self).count() games_won = total_games - games_lost - win_rate = (games_won / total_games) * 100 if total_games > 0 else 0.0 - + win_rate = (games_won / total_games) * 100 + return { 'total_games': total_games, 'games_won': games_won, 'games_lost': games_lost, 'win_rate': round(win_rate, 1) } - + class Meta: verbose_name = 'User' verbose_name_plural = 'Users' ordering = ['username'] + + +class Block(models.Model): + """Represents a unilateral user block between two users. + + A block prevents the ``blocked`` user from interacting with the + ``blocker`` (e.g., sending messages, joining their lobby, sending invites). + + Attributes: + blocker (ForeignKey[User]): The user who initiated the block. + blocked (ForeignKey[User]): The user who is being blocked. + created_at (DateTimeField): Timestamp of when the block was created. + + Constraints: + - A user cannot block the same user more than once (unique_together). + - Indexed lookups for efficient permission checks. + + Example: + Block.objects.create(blocker=user1, blocked=user2) + """ + + blocker = models.ForeignKey( + User, + related_name="blocks_initiated", + on_delete=models.CASCADE + ) + blocked = models.ForeignKey( + User, + related_name="blocks_received", + on_delete=models.CASCADE + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("blocker", "blocked") + indexes = [ + models.Index(fields=["blocker", "blocked"]), + ] + + def __str__(self): + """Return a human-readable representation of the block relation. + + Returns: + str: A formatted string describing the block. + """ + return f"{self.blocker} blocked {self.blocked}" From 25fe35b29d302a66b9de974c7e9a2d6cd6ce70fd Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Fri, 14 Nov 2025 20:42:47 +0100 Subject: [PATCH 06/12] Migrations and test for Block model --- accounts/migrations/0003_block.py | 28 +++++++++++ accounts/tests/test_block_model.py | 76 ++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 accounts/migrations/0003_block.py create mode 100644 accounts/tests/test_block_model.py diff --git a/accounts/migrations/0003_block.py b/accounts/migrations/0003_block.py new file mode 100644 index 0000000..98c251a --- /dev/null +++ b/accounts/migrations/0003_block.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.6 on 2025-11-14 19:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_user_options'), + ] + + operations = [ + migrations.CreateModel( + name='Block', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('blocked', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks_received', to=settings.AUTH_USER_MODEL)), + ('blocker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks_initiated', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['blocker', 'blocked'], name='accounts_bl_blocker_746a2a_idx')], + 'unique_together': {('blocker', 'blocked')}, + }, + ), + ] diff --git a/accounts/tests/test_block_model.py b/accounts/tests/test_block_model.py new file mode 100644 index 0000000..dc1a3e9 --- /dev/null +++ b/accounts/tests/test_block_model.py @@ -0,0 +1,76 @@ +"""Pytest test suite for the Block model in the Durak card game application. + +This module contains unit tests for the accounts.models.Block model, +which represents unilateral user blocking relationships. The tests +cover creation, uniqueness constraints, string representation, +cascade deletions, and multiple block scenarios. +""" +import pytest +from django.db import IntegrityError, transaction + +from accounts.models import Block + + +@pytest.mark.django_db +def test_create_block(test_user, second_user): + """Test that a Block instance can be created successfully.""" + block = Block.objects.create(blocker=test_user, blocked=second_user) + + assert block.blocker == test_user + assert block.blocked == second_user + assert Block.objects.count() == 1 + + +@pytest.mark.django_db +def test_block_unique_constraint(test_user, second_user): + """Test that duplicate blocker-blocked pairs are not allowed.""" + Block.objects.create(blocker=test_user, blocked=second_user) + + with pytest.raises(IntegrityError): + with transaction.atomic(): + Block.objects.create(blocker=test_user, blocked=second_user) + + assert Block.objects.count() == 1 + + +@pytest.mark.django_db +def test_block_str_representation(test_user, second_user): + """Test __str__ returns a human-readable representation.""" + block = Block.objects.create(blocker=test_user, blocked=second_user) + + expected = f"{test_user} blocked {second_user}" + assert str(block) == expected + + +@pytest.mark.django_db +def test_delete_blocker_cascades(test_user, second_user): + """Test that deleting the blocker deletes related Block rows.""" + Block.objects.create(blocker=test_user, blocked=second_user) + + test_user.delete() + + assert Block.objects.count() == 0 + + +@pytest.mark.django_db +def test_delete_blocked_cascades(test_user, second_user): + """Test that deleting the blocked user deletes related Block rows.""" + Block.objects.create(blocker=test_user, blocked=second_user) + + second_user.delete() + + assert Block.objects.count() == 0 + + +@pytest.mark.django_db +def test_block_multiple_users(user_factory): + """Test blocking works across many dynamically created users.""" + u1 = user_factory(username="alpha") + u2 = user_factory(username="beta") + u3 = user_factory(username="gamma") + + Block.objects.create(blocker=u1, blocked=u2) + Block.objects.create(blocker=u1, blocked=u3) + Block.objects.create(blocker=u2, blocked=u3) + + assert Block.objects.count() == 3 From a46fda63093a49333804a59f30f8537213110e2a Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 15 Nov 2025 13:23:16 +0100 Subject: [PATCH 07/12] Changes in message's related models Tests not fixed yet, removed redundant fields from message model, added chat field, changed indexes, added lobby related fields to Chat model --- chat/consumers.py | 1 + ...chat_messag_lobby_i_96d6b6_idx_and_more.py | 53 +++++++++++++++++++ chat/models.py | 16 +++--- 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 chat/migrations/0006_remove_message_chat_messag_lobby_i_96d6b6_idx_and_more.py diff --git a/chat/consumers.py b/chat/consumers.py index 4f04064..5dee8b8 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -1,6 +1,7 @@ import json from channels.generic.websocket import AsyncWebsocketConsumer + class ChatConsumer(AsyncWebsocketConsumer): async def connect(self): self.room_name = self.scope["url_route"]["kwargs"]["room_name"] diff --git a/chat/migrations/0006_remove_message_chat_messag_lobby_i_96d6b6_idx_and_more.py b/chat/migrations/0006_remove_message_chat_messag_lobby_i_96d6b6_idx_and_more.py new file mode 100644 index 0000000..88b91aa --- /dev/null +++ b/chat/migrations/0006_remove_message_chat_messag_lobby_i_96d6b6_idx_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.6 on 2025-11-15 12:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0005_alter_chatparticipant_user'), + ('game', '0003_turn_move'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='message', + name='chat_messag_lobby_i_96d6b6_idx', + ), + migrations.RemoveIndex( + model_name='message', + name='chat_messag_sender__ba5b4a_idx', + ), + migrations.RemoveField( + model_name='message', + name='lobby', + ), + migrations.RemoveField( + model_name='message', + name='receiver', + ), + migrations.AddField( + model_name='chat', + name='is_lobby', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='chat', + name='lobby', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='chat', to='game.lobby'), + ), + migrations.AddField( + model_name='message', + name='chat', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat'), + preserve_default=False, + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['sender', 'chat', '-sent_at'], name='chat_messag_sender__19bd32_idx'), + ), + ] diff --git a/chat/models.py b/chat/models.py index 6458580..65eeeb7 100644 --- a/chat/models.py +++ b/chat/models.py @@ -45,6 +45,14 @@ class Chat(models.Model): name = models.CharField(max_length=100, blank=True) description = models.TextField(blank=True) is_group = models.BooleanField(default=False) + is_lobby = models.BooleanField(default=False) + lobby = models.ForeignKey( + "game.Lobby", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="chat" + ) created_at = models.DateTimeField(default=timezone.now) class Meta: @@ -250,10 +258,7 @@ class Message(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) sender = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='sent_messages') - receiver = models.ForeignKey('accounts.User', on_delete=models.CASCADE, null=True, blank=True, - related_name='received_messages') - lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True, - related_name='messages') + chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") content = models.TextField() sent_at = models.DateTimeField(auto_now_add=True) @@ -361,7 +366,6 @@ class Meta: verbose_name_plural = 'Messages' ordering = ['-sent_at'] indexes = [ - models.Index(fields=['lobby', '-sent_at']), - models.Index(fields=['sender', 'receiver', '-sent_at']), + models.Index(fields=['sender', 'chat', '-sent_at']), models.Index(fields=['-sent_at']), ] From 78c073efd79b6cad82f4443390d9835063269163 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 15 Nov 2025 21:21:41 +0100 Subject: [PATCH 08/12] Fixed tests in chat application Fixed tests accordingly to new models and updated comments in chat/models.py --- chat/models.py | 292 +++++++++++-------------------------- chat/tests/test_chat.py | 8 +- chat/tests/test_message.py | 279 ++++++++++++++++++++++++++--------- chat/tests/test_queries.py | 210 ++++++++++++++++++++------ 4 files changed, 467 insertions(+), 322 deletions(-) diff --git a/chat/models.py b/chat/models.py index 65eeeb7..414401b 100644 --- a/chat/models.py +++ b/chat/models.py @@ -2,13 +2,12 @@ This module defines the core models for managing chat rooms and their participants in the Durak online multiplayer system. It includes models for representing chats, -user roles, and membership management. +chat membership, and stored messages. Classes: - Chat: Represents a chat room (group or private) used for communication. - ChatParticipant: Defines user participation in a chat, including their role - and join date. - Message: Represents a single message sent between users in a chat. + Chat: Represents a chat room (group, lobby or private). + ChatParticipant: Defines user participation in a chat with assigned roles. + Message: Represents a message sent inside a chat. """ import uuid @@ -21,31 +20,32 @@ class Chat(models.Model): - """Represents a chat room, either group or private. - - Each chat can contain multiple participants and messages. This model is used - to logically separate different communication contexts (e.g., private DM or - group lobby discussion). - - Attributes: - id (UUIDField): Primary key (UUID4) for unique chat identification. - name (CharField): Optional name of the chat room (e.g., "Lobby 1"). - description (TextField): Optional text describing the chat purpose. - is_group (BooleanField): Defines if chat is a group or private conversation. - created_at (DateTimeField): Timestamp for chat creation. - - Example: - chat = Chat.objects.create( - name="General Lobby", - description="Main lobby for all players", - is_group=True - ) - """ + """Represents a chat room (private, group, or lobby). + + Chats are used to isolate different communication contexts in the game: + - private chats (DM between two users) + - group chats + - automatically created lobby chats (is_lobby=True) + + Messages are always attached to a Chat, not directly to a Lobby or users. + + Attributes: + id (UUID): Unique identifier for the chat. + name (str): Optional name (e.g. "Lobby #1"). + description (str): Optional description. + is_group (bool): Whether the chat supports multiple participants. + is_lobby (bool): Whether the chat belongs to a game lobby. + lobby (ForeignKey): Optional reference to a Lobby object. + created_at (datetime): Timestamp of creation. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=100, blank=True) description = models.TextField(blank=True) + is_group = models.BooleanField(default=False) is_lobby = models.BooleanField(default=False) + lobby = models.ForeignKey( "game.Lobby", on_delete=models.CASCADE, @@ -53,6 +53,7 @@ class Chat(models.Model): blank=True, related_name="chat" ) + created_at = models.DateTimeField(default=timezone.now) class Meta: @@ -64,45 +65,38 @@ class Meta: ] def __str__(self): - """Return a readable representation of the chat. - - Returns: - str: Chat name or fallback to ID. - """ + """Return the chat name if available, otherwise fallback to ID.""" return self.name or f"Chat {self.id}" def get_participants(self): - """Return a QuerySet of users participating in this chat. + """Return all users currently participating in this chat. Returns: - QuerySet[User]: Users who are members of this chat. + QuerySet[User]: Distinct list of users. """ - return User.objects.filter(chat_participations__chat=self).select_related().distinct() + return User.objects.filter(chat_participations__chat=self).distinct() def has_participant(self, user): - """Check if a user is a member of the chat. + """Determine whether a given user is part of this chat. Args: user (User): The user to check. Returns: - bool: True if user participates in this chat. + bool: True if the user participates in the chat. """ return ChatParticipant.objects.filter(chat=self, user=user).exists() def add_participant(self, user, role="member"): - """Add a user to the chat with an optional role. - - If the user already exists in the chat, do not create a duplicate; optionally - update their role if it differs. + """Add a user to the chat or update their role. Args: - user (User): The user to add. - role (str): Role in the chat ("owner", "admin", or "member"). + user (User): User to add. + role (str): One of: "owner", "admin", "member". + Returns: - ChatParticipant: The participant instance and a boolean created flag. + tuple(ChatParticipant, bool): participant instance and created flag """ - with transaction.atomic(): participant, created = ChatParticipant.objects.get_or_create( chat=self, @@ -116,58 +110,45 @@ def add_participant(self, user, role="member"): return participant, created def remove_participant(self, user): - """Remove a user from the chat. + """Remove a participant from the chat. Args: - user (User): The user to remove. + user (User): User to remove. Returns: - int: Number of deleted rows (0 or 1). + int: Number of deleted records (0 or 1). """ return ChatParticipant.objects.filter(chat=self, user=user).delete()[0] def get_owners(self): - """Return a QuerySet of ChatParticipant objects with role 'owner'. - - Returns: - QuerySet[ChatParticipant]: Participant records for owners (selects related user). - """ + """Return all owners of this chat.""" return ChatParticipant.objects.filter(chat=self, role="owner").select_related("user") def get_admins(self): - """Return a QuerySet of ChatParticipant objects with role 'admin'. - - Returns: - QuerySet[ChatParticipant]: Participant records for admins (selects related user). - """ - return ChatParticipant.objects.filter(chat=self, role="admin").select_related("user") + """Return all admins (role admin or owner).""" + return ChatParticipant.objects.filter( + chat=self, role__in=["admin", "owner"] + ).select_related("user") class ChatParticipant(models.Model): - """Defines a user's participation and role within a chat. + """Represents a user's membership in a chat with assigned permissions. - Each record represents one user's membership in one chat. Roles determine - their permissions (e.g., ownership, admin privileges, or regular member). + Each user can belong to multiple chats and have different roles in each. - Attributes: - id (UUIDField): Primary key (UUID4) for unique participant record. - chat (ForeignKey): The chat this user belongs to. - user (ForeignKey): The user participating in the chat. - role (CharField): User's role in the chat ("owner", "admin", "member"). - joined_at (DateTimeField): Timestamp of when the user joined the chat. + Attributes: + chat (Chat): The chat the user participates in. + user (User): The participating user. + role (str): Permission level ("owner", "admin", "member"). + joined_at (datetime): When the user joined the chat. + """ - Example: - participant = ChatParticipant.objects.create( - chat=chat, - user=user, - role="admin" - ) - """ ROLE_CHOICES = [ ('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member'), ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) chat = models.ForeignKey(Chat, on_delete=models.CASCADE) user = models.ForeignKey('accounts.User', related_name='chat_participations', on_delete=models.CASCADE) @@ -185,75 +166,42 @@ class Meta: ] def __str__(self): - """Return readable representation of the participant. - - Returns: - str: User’s username and chat name. - """ - return f"User {self.user.username} in {self.chat} chat" + return f"{self.user.username} in {self.chat}" def is_owner(self): - """Check if the participant is the chat owner. - - Returns: - bool: True if participant has 'owner' role. - """ + """Return True if participant is the owner.""" return self.role == "owner" def is_admin(self): - """Check if the participant has admin privileges. - - Returns: - bool: True if role is 'admin' or 'owner'. - """ + """Return True if participant has admin or owner rights.""" return self.role in ("admin", "owner") def promote(self): - """Promote participant to admin if not already.""" + """Promote user to admin.""" if self.role == "member": self.role = "admin" self.save(update_fields=["role"]) def demote(self): - """Demote participant to member if currently admin.""" + """Demote admin to member.""" if self.role == "admin": self.role = "member" self.save(update_fields=["role"]) class Message(models.Model): - """Chat message model for storing messages in lobbies and private conversations. - - This model handles both lobby-based group messages and private direct messages - between users. Messages can be associated with either a lobby (for public chat) - or a receiver (for private messaging). - + """Represents a text message inside a chat. + + Messages belong strictly to a Chat instance. Lobby messages and private + messages are simply different chat types — there are no separate fields + for lobby/receiver. + Attributes: - id (UUIDField): Primary key using UUID4 for unique message identification. - sender (ForeignKey): Reference to the User who sent the message. - receiver (ForeignKey, optional): Target User for private messages. Null for lobby messages. - lobby (ForeignKey, optional): Target Lobby for group messages. Null for private messages. - content (TextField): The actual message content/text. - sent_at (DateTimeField): Timestamp when the message was created (auto-generated). - - Note: - Either 'receiver' or 'lobby' should be set, but not both. This creates a logical - separation between private messages and lobby-based group chat. - - Example: - # Create a lobby message - Message.objects.create( - sender=user, - lobby=lobby, - content="Hello everyone!" - ) - - # Create a private message - Message.objects.create( - sender=user1, - receiver=user2, - content="Private message" - ) + id (UUID): Unique message identifier. + sender (User): The user who sent the message. + chat (Chat): Chat to which the message belongs. + content (str): Text content. + sent_at (datetime): Timestamp of message creation. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -263,103 +211,31 @@ class Message(models.Model): sent_at = models.DateTimeField(auto_now_add=True) def __str__(self): - """Return string representation of the message. - - Returns: - str: Formatted string showing sender and message preview. - """ + """Return a concise textual preview.""" preview = self.content[:50] + "..." if len(self.content) > 50 else self.content return f"{self.sender.username}: {preview}" - def is_private(self): - """Check if this is a private message between users. - - Returns: - bool: True if message has a receiver (private), False if lobby message. - """ - return self.receiver is not None - def is_lobby_message(self): - """Check if this is a lobby/group message. - - Returns: - bool: True if message belongs to a lobby, False if private message. - """ - return self.lobby is not None + """Determine if this message belongs to a lobby chat.""" + return self.chat.is_lobby + + def is_private(self): + """Determine if this is a private 1-on-1 message.""" + return not self.chat.is_group and not self.chat.is_lobby def get_chat_context(self): - """Get the context (lobby or private chat) for this message. - - Returns: - dict: Dictionary with context type and relevant object. - """ - if self.lobby: - return { - 'type': 'lobby', - 'context': self.lobby, - 'context_name': self.lobby.name - } - elif self.receiver: - return { - 'type': 'private', - 'context': self.receiver, - 'context_name': f"Private chat with {self.receiver.username}" - } - return {'type': 'unknown', 'context': None, 'context_name': 'Unknown'} - - @classmethod - def get_lobby_messages(cls, lobby, limit=50): - """Get recent messages for a specific lobby. - - Args: - lobby (Lobby): The lobby to get messages for. - limit (int): Maximum number of messages to retrieve. - - Returns: - QuerySet: Recent messages in the lobby. - """ - return cls.objects.filter(lobby=lobby).order_by('-sent_at')[:limit] + """Return structured information about the chat type. - @classmethod - def get_private_conversation(cls, user1, user2, limit=50): - """Get recent private messages between two users. - - Args: - user1 (User): First user in the conversation. - user2 (User): Second user in the conversation. - limit (int): Maximum number of messages to retrieve. - Returns: - QuerySet: Recent messages between the users. + dict: {type: 'private'|'group'|'lobby', name: str} """ - return cls.objects.filter( - models.Q(sender=user1, receiver=user2) | - models.Q(sender=user2, receiver=user1), - lobby__isnull=True - ).order_by('-sent_at')[:limit] - - def clean(self): - """Validate that message has either lobby or receiver, but not both. - - Raises: - ValidationError: If both lobby and receiver are set, or if neither is set. - """ - from django.core.exceptions import ValidationError + if self.chat.is_lobby: + return {"type": "lobby", "name": self.chat.name or "Lobby"} - if self.lobby and self.receiver: - raise ValidationError("Message cannot have both lobby and receiver.") - if not self.lobby and not self.receiver: - raise ValidationError("Message must have either lobby or receiver.") + if self.chat.is_group: + return {"type": "group", "name": self.chat.name or "Group Chat"} - def save(self, *args, **kwargs): - """Override save to ensure message validation. - - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - """ - self.clean() - super().save(*args, **kwargs) + return {"type": "private", "name": "Private Chat"} class Meta: verbose_name = 'Message' diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index d2cca7d..bc88d34 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -60,11 +60,13 @@ def test_get_owners_and_admins(self, user_factory): owners = chat.get_owners() admins = chat.get_admins() - + admin_users = [admin.user for admin in admins] assert owners.count() == 1 assert owners.first().user == owner - assert admins.count() == 1 - assert admins.first().user == admin + assert admins.count() == 2 + assert owner in admin_users + assert admin in admin_users + assert member not in admin_users @pytest.mark.django_db diff --git a/chat/tests/test_message.py b/chat/tests/test_message.py index 2d9d2f1..79c06f7 100644 --- a/chat/tests/test_message.py +++ b/chat/tests/test_message.py @@ -1,115 +1,260 @@ """Tests for the Message model in the chat app.""" import pytest -from django.core.exceptions import ValidationError -from chat.models import Message +from django.utils import timezone + +from chat.models import Chat, Message @pytest.mark.django_db class TestMessageModel: - """Test suite for Message model.""" + """Test suite for Message model with chat-based messaging.""" + + def test_message_creation_in_lobby_chat(self, test_user, basic_lobby): + """Tests that messages are created correctly for lobby-attached chats. + + This scenario represents a group chat bound to a specific lobby. + """ + chat = Chat.objects.create( + name="Lobby Chat", + is_group=True, + is_lobby=True, + lobby=basic_lobby, + ) - def test_private_message_creation(self, test_user, second_user): - """Tests that private Message instances are created correctly.""" message = Message.objects.create( sender=test_user, - receiver=second_user, - content="Hello, this is a private message!" + chat=chat, + content="Hello lobby chat!", ) + assert message.sender == test_user - assert message.receiver == second_user - assert message.lobby is None - assert message.content == "Hello, this is a private message!" + assert message.chat == chat + assert message.chat.is_lobby is True + assert message.chat.lobby == basic_lobby + assert message.content == "Hello lobby chat!" + assert message.sent_at is not None + + def test_message_creation_in_private_chat(self, test_user, second_user): + """Tests that messages are created correctly for private chats. + + This scenario represents a direct chat between two users without an attached lobby. + """ + private_chat = Chat.objects.create( + name="Private Chat", + is_group=False, + is_lobby=False, + ) - def test_lobby_message_creation(self, test_user, basic_lobby): - """Tests that lobby Message instances are created correctly.""" message = Message.objects.create( sender=test_user, - lobby=basic_lobby, - content="Hello everyone in the lobby!" + chat=private_chat, + content="Hello in private chat!", ) + assert message.sender == test_user - assert message.lobby == basic_lobby - assert message.receiver is None - assert message.content == "Hello everyone in the lobby!" + assert message.chat == private_chat + assert message.chat.is_group is False + assert message.chat.is_lobby is False + assert message.content == "Hello in private chat!" + assert message.sent_at is not None - def test_message_uuid_generation(self, test_user, second_user): + def test_message_uuid_generation(self, test_user, basic_lobby): """Tests that UUID is automatically generated for messages.""" + chat = Chat.objects.create( + name="UUID Chat", + is_group=True, + is_lobby=True, + lobby=basic_lobby, + ) + message = Message.objects.create( - sender=test_user, receiver=second_user, content="Test" + sender=test_user, + chat=chat, + content="Test", ) + assert message.id is not None + # UUID4 format length (including dashes) should be 36 characters. assert len(str(message.id)) == 36 - def test_message_sent_at_auto_generation(self, test_user, second_user): - """Tests that sent_at timestamp is automatically set.""" + def test_message_sent_at_auto_generation(self, test_user, basic_lobby): + """Tests that sent_at timestamp is automatically set on creation.""" + chat = Chat.objects.create( + name="Timestamp Chat", + is_group=True, + is_lobby=True, + lobby=basic_lobby, + ) + + before_creation = timezone.now() message = Message.objects.create( - sender=test_user, receiver=second_user, content="Test" + sender=test_user, + chat=chat, + content="Test timestamp", ) + assert message.sent_at is not None + # sent_at should not be earlier than the check before creation + assert message.sent_at >= before_creation + + def test_message_str_representation_short_content(self, test_user, basic_lobby): + """Tests string representation of Message for short content.""" + chat = Chat.objects.create( + name="Str Chat Short", + is_group=True, + is_lobby=True, + lobby=basic_lobby, + ) - def test_message_str_representation(self, test_user, second_user): - """Tests string representation of Message.""" short_content = "Short message" message = Message.objects.create( - sender=test_user, receiver=second_user, content=short_content + sender=test_user, + chat=chat, + content=short_content, ) + assert str(message) == f"{test_user.username}: {short_content}" - def test_is_private_method(self, test_user, second_user, basic_lobby): - """Tests is_private() method for private and lobby messages.""" - private_message = Message.objects.create( - sender=test_user, receiver=second_user, content="Private" + def test_message_str_representation_long_content_truncated( + self, + test_user, + basic_lobby, + ): + """Tests that long content is truncated in string representation.""" + chat = Chat.objects.create( + name="Str Chat Long", + is_group=True, + is_lobby=True, + lobby=basic_lobby, + ) + + long_content = "x" * 80 + message = Message.objects.create( + sender=test_user, + chat=chat, + content=long_content, ) - lobby_message = Message.objects.create( - sender=test_user, lobby=basic_lobby, content="Public" + + preview = long_content[:50] + "..." + assert str(message) == f"{test_user.username}: {preview}" + + def test_message_ordering_by_sent_at(self, test_user, basic_lobby): + """Tests default ordering: newest messages should come first.""" + chat = Chat.objects.create( + name="Ordering Chat", + is_group=True, + is_lobby=True, + lobby=basic_lobby, ) - assert private_message.is_private() is True - assert lobby_message.is_private() is False - def test_is_lobby_message_method(self, test_user, second_user, basic_lobby): - """Tests is_lobby_message() method for private and lobby messages.""" - private_message = Message.objects.create( - sender=test_user, receiver=second_user, content="Private" + older_message = Message.objects.create( + sender=test_user, + chat=chat, + content="Older message", ) - lobby_message = Message.objects.create( - sender=test_user, lobby=basic_lobby, content="Public" + newer_message = Message.objects.create( + sender=test_user, + chat=chat, + content="Newer message", ) - assert lobby_message.is_lobby_message() is True - assert private_message.is_lobby_message() is False - def test_get_chat_context(self, test_user, second_user, basic_lobby): - """Tests get_chat_context() for lobby and private messages.""" - private_message = Message.objects.create( - sender=test_user, receiver=second_user, content="Test" + messages = list(Message.objects.filter(chat=chat)) + # Meta.ordering = ['-sent_at'], newest first + assert messages[0] == newer_message + assert messages[1] == older_message + + def test_is_lobby_message_and_is_private_flags(self, test_user, basic_lobby): + """Tests is_lobby_message() and is_private() according to chat type.""" + lobby_chat = Chat.objects.create( + name="Lobby Chat", + is_group=True, + is_lobby=True, + lobby=basic_lobby, ) - lobby_message = Message.objects.create( - sender=test_user, lobby=basic_lobby, content="Test" + private_chat = Chat.objects.create( + name="Private Chat", + is_group=False, + is_lobby=False, + ) + group_chat = Chat.objects.create( + name="Group Chat", + is_group=True, + is_lobby=False, ) - private_context = private_message.get_chat_context() - assert private_context['type'] == 'private' - assert private_context['context'] == second_user + lobby_msg = Message.objects.create( + sender=test_user, + chat=lobby_chat, + content="Lobby", + ) + private_msg = Message.objects.create( + sender=test_user, + chat=private_chat, + content="Private", + ) + group_msg = Message.objects.create( + sender=test_user, + chat=group_chat, + content="Group", + ) + + assert lobby_msg.is_lobby_message() is True + assert lobby_msg.is_private() is False - lobby_context = lobby_message.get_chat_context() - assert lobby_context['type'] == 'lobby' - assert lobby_context['context'] == basic_lobby + assert private_msg.is_lobby_message() is False + assert private_msg.is_private() is True - def test_clean_validation_both_lobby_and_receiver( - self, test_user, second_user, basic_lobby + assert group_msg.is_lobby_message() is False + assert group_msg.is_private() is False + + def test_get_chat_context_for_different_chat_types( + self, + test_user, + basic_lobby, ): - """Tests clean() raises ValidationError when both lobby and receiver are set.""" - message = Message( - sender=test_user, - receiver=second_user, + """Tests get_chat_context() for lobby, group, and private chats.""" + lobby_chat = Chat.objects.create( + name="Lobby Chat", + is_group=True, + is_lobby=True, lobby=basic_lobby, - content="Invalid" ) - with pytest.raises(ValidationError, match="both lobby and receiver"): - message.clean() + group_chat = Chat.objects.create( + name="Group Chat", + is_group=True, + is_lobby=False, + ) + private_chat = Chat.objects.create( + name="Private Chat", + is_group=False, + is_lobby=False, + ) + + lobby_msg = Message.objects.create( + sender=test_user, + chat=lobby_chat, + content="Lobby", + ) + group_msg = Message.objects.create( + sender=test_user, + chat=group_chat, + content="Group", + ) + private_msg = Message.objects.create( + sender=test_user, + chat=private_chat, + content="Private", + ) + + lobby_ctx = lobby_msg.get_chat_context() + assert lobby_ctx["type"] == "lobby" + assert "name" in lobby_ctx + + group_ctx = group_msg.get_chat_context() + assert group_ctx["type"] == "group" + assert "name" in group_ctx - def test_clean_validation_neither_lobby_nor_receiver(self, test_user): - """Tests clean() raises ValidationError when neither lobby nor receiver is set.""" - message = Message(sender=test_user, content="Invalid") - with pytest.raises(ValidationError, match="either lobby or receiver"): - message.clean() + private_ctx = private_msg.get_chat_context() + assert private_ctx["type"] == "private" + assert "name" in private_ctx diff --git a/chat/tests/test_queries.py b/chat/tests/test_queries.py index 7d21aa4..5c67624 100644 --- a/chat/tests/test_queries.py +++ b/chat/tests/test_queries.py @@ -1,90 +1,212 @@ -"""Tests for query methods on the Message model.""" +"""Tests for common query patterns on the Message model.""" import pytest -from chat.models import Message + +from chat.models import Chat, Message from game.models import Lobby @pytest.mark.django_db class TestMessageQueries: - """Test suite for class methods on Message that perform queries.""" + """Test suite for typical query scenarios around Message objects.""" @pytest.fixture(autouse=True) def set_up(self, test_user, second_user, basic_lobby): - """Sets up users and a lobby for the tests.""" + """Prepare users, lobby, and base chats for the query tests. + + Creates: + - user1, user2: Two distinct users. + - lobby: A lobby instance. + - lobby_chat: Group chat attached to the lobby. + - private_chat: Direct private chat between user1 and user2. + """ self.user1 = test_user self.user2 = second_user - self.lobby = basic_lobby + self.lobby: Lobby = basic_lobby + + self.lobby_chat = Chat.objects.create( + name="Lobby Chat", + is_group=True, + is_lobby=True, + lobby=self.lobby, + ) + self.private_chat = Chat.objects.create( + name="Private Chat", + is_group=False, + is_lobby=False, + ) def test_get_lobby_messages(self): - """Tests that get_lobby_messages() retrieves only relevant lobby messages.""" - msg1 = Message.objects.create(sender=self.user1, lobby=self.lobby, content="1") - msg2 = Message.objects.create(sender=self.user2, lobby=self.lobby, content="2") - # Private message, should not be included - Message.objects.create(sender=self.user1, receiver=self.user2, content="private") - - messages = list(Message.get_lobby_messages(self.lobby)) + """Tests that lobby messages are retrieved only from the lobby chat. + + Only messages attached to the lobby_chat should be returned and they + must be ordered by sent_at descending (newest first). + """ + msg1 = Message.objects.create( + sender=self.user1, + chat=self.lobby_chat, + content="1", + ) + msg2 = Message.objects.create( + sender=self.user2, + chat=self.lobby_chat, + content="2", + ) + # Message in a different chat, should not be included + Message.objects.create( + sender=self.user1, + chat=self.private_chat, + content="private", + ) + + messages = list( + Message.objects.filter(chat=self.lobby_chat).order_by("-sent_at") + ) assert len(messages) == 2 - # Should be ordered by sent_at descending (newest first) assert messages[0] == msg2 assert messages[1] == msg1 def test_get_lobby_messages_limit(self): - """Tests that get_lobby_messages() respects the limit parameter.""" + """Tests that limiting lobby messages via slice returns expected count.""" for i in range(5): Message.objects.create( - sender=self.user1, lobby=self.lobby, content=f"Msg {i}" + sender=self.user1, + chat=self.lobby_chat, + content=f"Msg {i}", ) - messages = list(Message.get_lobby_messages(self.lobby, limit=3)) + messages = list( + Message.objects.filter(chat=self.lobby_chat) + .order_by("-sent_at")[:3] + ) assert len(messages) == 3 def test_get_lobby_messages_empty(self, lobby_factory): - """Tests get_lobby_messages() for a lobby with no messages.""" + """Tests lobby chat with no messages returns an empty result.""" empty_lobby = lobby_factory(owner=self.user1, name="Empty") - messages = list(Message.get_lobby_messages(empty_lobby)) + empty_lobby_chat = Chat.objects.create( + name="Empty Lobby Chat", + is_group=True, + is_lobby=True, + lobby=empty_lobby, + ) + + messages = list( + Message.objects.filter(chat=empty_lobby_chat).order_by("-sent_at") + ) assert len(messages) == 0 def test_get_private_conversation(self, user_factory): - """Tests get_private_conversation() retrieves a full conversation.""" - user3 = user_factory(username='user3') - # Conversation between user1 and user2 - msg1 = Message.objects.create(sender=self.user1, receiver=self.user2, content="Hi") - msg2 = Message.objects.create(sender=self.user2, receiver=self.user1, content="Hello") - # Other messages that should be ignored - Message.objects.create(sender=self.user1, lobby=self.lobby, content="Lobby msg") - Message.objects.create(sender=self.user1, receiver=user3, content="To user3") + """Tests retrieving a private conversation within a dedicated chat. + + Only messages inside the given private chat between user1 and user2 + should be returned; other chats or users must be ignored. + """ + user3 = user_factory(username="user3") + + # Conversation between user1 and user2 in the private_chat + msg1 = Message.objects.create( + sender=self.user1, + chat=self.private_chat, + content="Hi", + ) + msg2 = Message.objects.create( + sender=self.user2, + chat=self.private_chat, + content="Hello", + ) - messages = list(Message.get_private_conversation(self.user1, self.user2)) + # Other messages that should be ignored + other_private_chat = Chat.objects.create( + name="Other Private", + is_group=False, + is_lobby=False, + ) + Message.objects.create( + sender=self.user1, + chat=self.lobby_chat, + content="Lobby msg", + ) + Message.objects.create( + sender=self.user1, + chat=other_private_chat, + content="To user3", + ) + + messages = list( + Message.objects.filter(chat=self.private_chat).order_by("-sent_at") + ) assert len(messages) == 2 assert msg1 in messages assert msg2 in messages def test_get_private_conversation_order(self): - """Tests get_private_conversation() returns messages in descending order.""" - msg1 = Message.objects.create(sender=self.user1, receiver=self.user2, content="First") - msg2 = Message.objects.create(sender=self.user2, receiver=self.user1, content="Second") - - messages = list(Message.get_private_conversation(self.user1, self.user2)) + """Tests that private messages are ordered newest first inside a chat.""" + msg1 = Message.objects.create( + sender=self.user1, + chat=self.private_chat, + content="First", + ) + msg2 = Message.objects.create( + sender=self.user2, + chat=self.private_chat, + content="Second", + ) + + messages = list( + Message.objects.filter(chat=self.private_chat).order_by("-sent_at") + ) assert messages[0] == msg2 assert messages[1] == msg1 def test_get_private_conversation_limit(self): - """Tests get_private_conversation() respects the limit parameter.""" + """Tests limiting private conversation size via slice.""" for i in range(5): Message.objects.create( - sender=self.user1, receiver=self.user2, content=f"Msg {i}" + sender=self.user1, + chat=self.private_chat, + content=f"Msg {i}", ) - messages = list(Message.get_private_conversation(self.user1, self.user2, limit=3)) + messages = list( + Message.objects.filter(chat=self.private_chat) + .order_by("-sent_at")[:3] + ) assert len(messages) == 3 - def test_get_private_conversation_symmetry(self): - """Tests get_private_conversation() works regardless of parameter order.""" - Message.objects.create(sender=self.user1, receiver=self.user2, content="Test") - - messages1 = list(Message.get_private_conversation(self.user1, self.user2)) - messages2 = list(Message.get_private_conversation(self.user2, self.user1)) - - assert len(messages1) == 1 - assert messages1 == messages2 + def test_private_conversation_is_chat_specific(self): + """Tests that private conversation is isolated per chat instance. + + Messages from another private chat between the same users should not + appear when querying by the original chat. + """ + # Messages in the original private_chat + Message.objects.create( + sender=self.user1, + chat=self.private_chat, + content="Original chat", + ) + + # Same users, but another chat + another_private_chat = Chat.objects.create( + name="Another Private Chat", + is_group=False, + is_lobby=False, + ) + Message.objects.create( + sender=self.user1, + chat=another_private_chat, + content="Another chat message", + ) + + messages_original = list( + Message.objects.filter(chat=self.private_chat).order_by("-sent_at") + ) + messages_other = list( + Message.objects.filter(chat=another_private_chat).order_by("-sent_at") + ) + + assert len(messages_original) == 1 + assert len(messages_other) == 1 + assert messages_original[0].chat == self.private_chat + assert messages_other[0].chat == another_private_chat From 72abe1b3d7d36c91ad746351f2fa40cfe5a42b61 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 22 Nov 2025 21:22:02 +0100 Subject: [PATCH 09/12] Chat consumer and API endpoints --- Fools_Arena/urls.py | 4 +-- chat/api_urls.py | 10 ++++++ chat/api_views.py | 23 +++++++++++++ chat/consumers.py | 81 +++++++++++++++++++++++++++++++++------------ chat/routing.py | 6 ++-- chat/serializers.py | 16 +++++++++ chat/views.py | 1 + 7 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 chat/api_urls.py create mode 100644 chat/api_views.py create mode 100644 chat/serializers.py diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index fd2a248..f5345e3 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -24,14 +24,14 @@ urlpatterns = [ path("admin/", admin.site.urls), -# UI + # UI path("accounts/", include("accounts.urls")), path("chat/", include("chat.urls")), # API path("api/accounts/", include("accounts.api_urls")), + path("api/chat/", include("chat.api_urls")), ] # Add static files urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - diff --git a/chat/api_urls.py b/chat/api_urls.py new file mode 100644 index 0000000..db035b0 --- /dev/null +++ b/chat/api_urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .api_views import ChatListAPIView, ChatMessagesAPIView + +urlpatterns = [ + # All chat available for user + path('api/chats/', ChatListAPIView.as_view(), name='chat-list'), + + # Get all messages for a specific chat + path('api/chats//messages/', ChatMessagesAPIView.as_view(), name='chat-messages'), +] \ No newline at end of file diff --git a/chat/api_views.py b/chat/api_views.py new file mode 100644 index 0000000..86b2f7e --- /dev/null +++ b/chat/api_views.py @@ -0,0 +1,23 @@ +from rest_framework import generics, permissions +from .models import Chat, Message +from .serializers import MessageSerializer, ChatSerializer + + +class ChatMessagesAPIView(generics.ListAPIView): + serializer_class = MessageSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + chat_id = self.kwargs['chat_id'] + return Message.objects.filter( + chat_id=chat_id, + chat__chatparticipant__user=self.request.user + ).order_by('-sent_at')[:50] + + +class ChatListAPIView(generics.ListAPIView): + serializer_class = ChatSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Chat.objects.filter(chatparticipant__user=self.request.user) diff --git a/chat/consumers.py b/chat/consumers.py index 5dee8b8..73ecc6d 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -1,19 +1,37 @@ -import json from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from django.contrib.auth import get_user_model +from .models import Chat, ChatParticipant, Message +import json + +User = get_user_model() class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): - self.room_name = self.scope["url_route"]["kwargs"]["room_name"] - self.room_group_name = f"chat_{self.room_name}" + self.user = self.scope["user"] + self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"] + + # Check if user has access to the chat + if not await self.user_can_access_chat(): + await self.close(code=403) # Forbidden + return + + self.room_group_name = f"chat_{self.chat_id}" - # Подключаемся к группе (группы = комнаты) await self.channel_layer.group_add( self.room_group_name, self.channel_name ) + await self.accept() - await self.accept() # принимаем соединение + # Send last messages to the client + last_messages = await self.get_last_messages() + await self.send(text_data=json.dumps({ + "type": "last_messages", + "messages": last_messages + })) async def disconnect(self, close_code): await self.channel_layer.group_discard( @@ -21,25 +39,46 @@ async def disconnect(self, close_code): self.channel_name ) - # Принимаем сообщение от WebSocket async def receive(self, text_data): data = json.loads(text_data) - message = data["message"] + content = data.get("message", "").strip() - # Рассылаем сообщение всем в группе - await self.channel_layer.group_send( - self.room_group_name, - { - "type": "chat_message", - "message": message - } - ) + if content: + message = await self.create_message(content) + await self.channel_layer.group_send( + self.room_group_name, + { + "type": "chat_message", + "message": { + "id": str(message.id), + "sender": self.user.username, + "content": message.content, + "sent_at": str(message.sent_at) + } + } + ) - # Обработка сообщения от группы async def chat_message(self, event): - message = event["message"] + await self.send(text_data=json.dumps(event["message"])) - # Отправляем обратно клиенту - await self.send(text_data=json.dumps({ - "message": message - })) + @database_sync_to_async + def user_can_access_chat(self): + try: + chat = Chat.objects.get(id=self.chat_id) + except Chat.DoesNotExist: + return False + return ChatParticipant.objects.filter(chat=chat, user=self.user).exists() + + @database_sync_to_async + def create_message(self, content): + chat = Chat.objects.get(id=self.chat_id) + return Message.objects.create(sender=self.user, chat=chat, content=content) + + @database_sync_to_async + def get_last_messages(self, limit=50): + chat = Chat.objects.get(id=self.chat_id) + messages = chat.messages.order_by('-sent_at')[:limit] + return [ + {"id": str(m.id), "sender": m.sender.username, "content": m.content, "sent_at": str(m.sent_at)} + for m in reversed(messages) + ] diff --git a/chat/routing.py b/chat/routing.py index e9ab611..4eca576 100644 --- a/chat/routing.py +++ b/chat/routing.py @@ -1,7 +1,7 @@ from django.urls import re_path -from . import consumers - +from .consumers import ChatConsumer websocket_urlpatterns = [ - re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), + # Universal chat consumer for all chat types + re_path(r'ws/chat/(?P[0-9a-f-]+)/$', ChatConsumer.as_asgi()), ] diff --git a/chat/serializers.py b/chat/serializers.py new file mode 100644 index 0000000..896146d --- /dev/null +++ b/chat/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from .models import Message, Chat + + +class MessageSerializer(serializers.ModelSerializer): + sender = serializers.CharField(source='sender.username', read_only=True) + + class Meta: + model = Message + fields = ['id', 'sender', 'content', 'sent_at'] + + +class ChatSerializer(serializers.ModelSerializer): + class Meta: + model = Chat + fields = ['id', 'name', 'is_group', 'is_lobby', 'lobby'] diff --git a/chat/views.py b/chat/views.py index d7a84fc..3c53fe0 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,4 +1,5 @@ from django.shortcuts import render + def chat_view(request): return render(request, "chat.html") From a44173ad6fce2610239b98f60fd646114b14a497 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sun, 30 Nov 2025 19:29:32 +0100 Subject: [PATCH 10/12] Small fix in asgi.py Added django.setup() and changed import order --- Fools_Arena/asgi.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Fools_Arena/asgi.py b/Fools_Arena/asgi.py index 5b3ae86..bd3956b 100644 --- a/Fools_Arena/asgi.py +++ b/Fools_Arena/asgi.py @@ -9,16 +9,17 @@ import os -from channels.auth import AuthMiddlewareStack +import django from django.core.asgi import get_asgi_application +from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter -from Fools_Arena.routing import websocket_application - - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.settings") +django.setup() + +from Fools_Arena.routing import websocket_application application = ProtocolTypeRouter({ "http": get_asgi_application(), "websocket": AuthMiddlewareStack(websocket_application), -}) \ No newline at end of file +}) From ec3c1e18a7b5d53ce14eca741d2e30f439050d9d Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 2 May 2026 11:48:19 +0200 Subject: [PATCH 11/12] Private messaging --- Fools_Arena/settings.py | 2 + README.md | 29 ++- accounts/templates/base.html | 3 +- chat/admin.py | 64 +++---- chat/api_urls.py | 27 ++- chat/api_views.py | 107 +++++++++-- chat/consumers.py | 35 +++- chat/forms.py | 35 ++++ .../0007_chat_is_global_dm_pair_key.py | 36 ++++ ...reated_idx_chat_chat_is_glob_a0825e_idx.py | 18 ++ chat/models.py | 108 ++++++++++- chat/serializers.py | 72 +++++++- chat/services.py | 47 +++++ chat/templates/chat.html | 24 --- chat/templates/chat/inbox.html | 21 +++ chat/templates/chat/room.html | 82 +++++++++ chat/templates/chat/start_direct.html | 15 ++ chat/tests/test_chat.py | 19 ++ chat/tests/test_chat_template_views.py | 76 ++++++++ chat/tests/test_private_messaging_api.py | 115 ++++++++++++ chat/urls.py | 16 +- chat/views.py | 174 +++++++++++++++++- 22 files changed, 1026 insertions(+), 99 deletions(-) create mode 100644 chat/forms.py create mode 100644 chat/migrations/0007_chat_is_global_dm_pair_key.py create mode 100644 chat/migrations/0008_rename_chat_chat_global_created_idx_chat_chat_is_glob_a0825e_idx.py create mode 100644 chat/services.py delete mode 100644 chat/templates/chat.html create mode 100644 chat/templates/chat/inbox.html create mode 100644 chat/templates/chat/room.html create mode 100644 chat/templates/chat/start_direct.html create mode 100644 chat/tests/test_chat_template_views.py create mode 100644 chat/tests/test_private_messaging_api.py diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index e1d761c..fcf1d39 100644 --- a/Fools_Arena/settings.py +++ b/Fools_Arena/settings.py @@ -125,6 +125,8 @@ AUTH_USER_MODEL = 'accounts.User' +LOGIN_URL = "/accounts/login/" + # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ diff --git a/README.md b/README.md index d908f7a..f7c8cab 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,36 @@ docker compose exec web python manage.py collectstatic ``` ### 6. Work with Django -All commands should be executed inside the web container. Examples: +Run migrations, tests, and management commands **inside the `web` container** (after `docker compose up`): + ```bash -docker compose exec web python manage.py shell +docker compose exec web python manage.py migrate docker compose exec web python manage.py makemigrations -docker compose exec web pytest -v +docker compose exec web pytest -v +``` + +If containers are not running yet, you can use a one-off container (starts dependencies per Compose file): + +```bash +docker compose run --rm web python manage.py migrate +docker compose run --rm web pytest -v ``` +### Chat: API and templates +REST (session or token auth as configured for DRF): + +- `GET /api/chat/chats/` — list chats for the current user +- `POST /api/chat/chats/direct/` — JSON body `{ "other_user_id": "" }` to open or reuse a 1:1 chat +- `GET|POST /api/chat/chats//messages/` — list recent messages or `{ "content": "..." }` to post + +Same behaviour in server-rendered UI (login required): + +- `/chat/` — inbox +- `/chat/direct/new/` — start a direct chat by username +- `/chat//` — history, HTTP post form, optional WebSocket client block on the page + +Blocking uses `accounts.Block`: the blocked user cannot send **direct** messages to the blocker; lobby chats are unchanged. + ### 7. Stop containers ```bash docker compose down diff --git a/accounts/templates/base.html b/accounts/templates/base.html index 61e5dd0..d40fa40 100644 --- a/accounts/templates/base.html +++ b/accounts/templates/base.html @@ -10,7 +10,8 @@

Fools Arena

diff --git a/chat/admin.py b/chat/admin.py index 18b3407..b2577a1 100644 --- a/chat/admin.py +++ b/chat/admin.py @@ -58,16 +58,14 @@ class MessageAdmin(admin.ModelAdmin): list_filter = ( 'sent_at', ('sender', admin.RelatedOnlyFieldListFilter), - ('receiver', admin.RelatedOnlyFieldListFilter), - ('lobby', admin.RelatedOnlyFieldListFilter), + ('chat', admin.RelatedOnlyFieldListFilter), ) search_fields = ( 'content', 'sender__username', 'sender__email', - 'receiver__username', - 'lobby__name', + 'chat__name', ) readonly_fields = ( @@ -90,9 +88,9 @@ class MessageAdmin(admin.ModelAdmin): 'fields': ('id', 'content', 'content_preview_formatted'), 'description': 'Core message content and identification.' }), - ('Participants', { - 'fields': ('sender', 'receiver', 'lobby'), - 'description': 'Users and contexts involved in this message.' + ('Chat', { + 'fields': ('sender', 'chat'), + 'description': 'Message context (private, lobby, global, or group chat).' }), ('Message Analysis', { 'fields': ('message_type_display', 'chat_context_display', 'character_count', 'word_count'), @@ -141,6 +139,10 @@ def message_type_display(self, obj): return format_html( '💬 Lobby' ) + elif obj.chat.is_global: + return format_html( + '🌐 Global' + ) return format_html( '❓ Unknown' ) @@ -157,22 +159,30 @@ def chat_context_display(self, obj): str: HTML formatted context information with admin links. """ context = obj.get_chat_context() - - if context['type'] == 'lobby' and obj.lobby: - lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + + if context['type'] == 'lobby' and obj.chat.lobby_id: + lobby = obj.chat.lobby + lobby_url = reverse('admin:game_lobby_change', args=[lobby.pk]) return format_html( '📋 {}', lobby_url, - obj.lobby.name + lobby.name ) - elif context['type'] == 'private' and obj.receiver: - receiver_url = reverse('admin:accounts_user_change', args=[obj.receiver.pk]) + if context['type'] == 'private': + other = obj.chat.get_other_participant(obj.sender) + if other: + user_url = reverse('admin:accounts_user_change', args=[other.pk]) + return format_html( + '👤 Private with {}', + user_url, + other.username + ) + if context['type'] == 'global': return format_html( - '👤 Private with {}', - receiver_url, - obj.receiver.username + '🌐 {}', + context['name'] ) - + return format_html('❓ Unknown Context') chat_context_display.short_description = "Chat Context" @@ -288,11 +298,8 @@ def get_queryset(self, request): """ return super().get_queryset(request).select_related( 'sender', - 'receiver', - 'lobby' - ).prefetch_related( - 'sender__sent_messages', - 'receiver__received_messages' + 'chat', + 'chat__lobby', ) def get_readonly_fields(self, request, obj=None): @@ -309,7 +316,7 @@ def get_readonly_fields(self, request, obj=None): # Non-superusers cannot edit core message data if not request.user.is_superuser: - readonly.extend(['sender', 'receiver', 'lobby', 'content']) + readonly.extend(['sender', 'chat', 'content']) return readonly @@ -396,15 +403,4 @@ def save_model(self, request, obj, form, change): # Log message creation for audit purposes pass - # Validate message before saving - try: - obj.clean() - except Exception as e: - self.message_user( - request, - f"Validation error: {e}", - level='ERROR' - ) - return - super().save_model(request, obj, form, change) diff --git a/chat/api_urls.py b/chat/api_urls.py index db035b0..dd58ca9 100644 --- a/chat/api_urls.py +++ b/chat/api_urls.py @@ -1,10 +1,23 @@ +"""URL routes for the chat REST API. + +These patterns are included under ``api/chat/`` in the project root ``urls.py``, so +effective paths are: + + /api/chat/chats/ + /api/chat/chats/direct/ + /api/chat/chats//messages/ +""" + from django.urls import path -from .api_views import ChatListAPIView, ChatMessagesAPIView -urlpatterns = [ - # All chat available for user - path('api/chats/', ChatListAPIView.as_view(), name='chat-list'), +from .api_views import ChatListAPIView, ChatMessagesAPIView, DirectChatCreateAPIView - # Get all messages for a specific chat - path('api/chats//messages/', ChatMessagesAPIView.as_view(), name='chat-messages'), -] \ No newline at end of file +urlpatterns = [ + path("chats/", ChatListAPIView.as_view(), name="chat-list"), + path("chats/direct/", DirectChatCreateAPIView.as_view(), name="chat-direct-create"), + path( + "chats//messages/", + ChatMessagesAPIView.as_view(), + name="chat-messages", + ), +] diff --git a/chat/api_views.py b/chat/api_views.py index 86b2f7e..cf0b8a9 100644 --- a/chat/api_views.py +++ b/chat/api_views.py @@ -1,23 +1,106 @@ -from rest_framework import generics, permissions +"""REST API views for chat listing, direct chats, and messages.""" + +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from rest_framework import generics, permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + from .models import Chat, Message -from .serializers import MessageSerializer, ChatSerializer +from .serializers import ( + ChatSerializer, + DirectChatCreateSerializer, + MessageCreateSerializer, + MessageSerializer, +) +from .services import assert_can_send_message + +User = get_user_model() -class ChatMessagesAPIView(generics.ListAPIView): - serializer_class = MessageSerializer +class ChatListAPIView(generics.ListAPIView): + """List chats the current user participates in.""" + + serializer_class = ChatSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): - chat_id = self.kwargs['chat_id'] - return Message.objects.filter( - chat_id=chat_id, - chat__chatparticipant__user=self.request.user - ).order_by('-sent_at')[:50] + """Return distinct chats for the request user, newest first.""" + return ( + Chat.objects.filter(chatparticipant__user=self.request.user) + .distinct() + .order_by("-created_at") + ) + def get_serializer_context(self): + """Attach ``request`` for peer fields on DM chats.""" + ctx = super().get_serializer_context() + ctx["request"] = self.request + return ctx + + +class DirectChatCreateAPIView(APIView): + """Open or create a private 1-on-1 chat with another user by id.""" -class ChatListAPIView(generics.ListAPIView): - serializer_class = ChatSerializer permission_classes = [permissions.IsAuthenticated] + def post(self, request): + """Create or return the DM ``Chat`` for the authenticated user and ``other_user_id``. + + Returns: + Response: Serialized chat; status 201 if created, 200 if it already existed. + + Raises: + ValidationError: If serializer input is invalid (handled by DRF). + """ + ser = DirectChatCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + other = get_object_or_404(User, pk=ser.validated_data["other_user_id"]) + + if other.pk == request.user.pk: + return Response( + {"detail": "Cannot open a direct chat with yourself."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + chat, created = Chat.objects.get_or_create_direct(request.user, other) + out = ChatSerializer(chat, context={"request": request}) + return Response(out.data, status=201 if created else 200) + + +class ChatMessagesAPIView(generics.ListCreateAPIView): + """List recent messages in a chat or post a new message.""" + + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + """Use create serializer for POST, read serializer for GET.""" + if self.request.method == "POST": + return MessageCreateSerializer + return MessageSerializer + def get_queryset(self): - return Chat.objects.filter(chatparticipant__user=self.request.user) + """Messages for this chat, visible only to participants.""" + chat_id = self.kwargs["chat_id"] + return ( + Message.objects.filter( + chat_id=chat_id, + chat__chatparticipant__user=self.request.user, + ) + .distinct() + .order_by("-sent_at")[:50] + ) + + def perform_create(self, serializer): + """Persist a message after membership and block checks.""" + chat = get_object_or_404(Chat, pk=self.kwargs["chat_id"]) + if not chat.has_participant(self.request.user): + raise PermissionDenied("You are not a member of this chat.") + + try: + assert_can_send_message(chat, self.request.user) + except PermissionError as exc: + raise PermissionDenied(str(exc)) from exc + + serializer.save(sender=self.request.user, chat=chat) diff --git a/chat/consumers.py b/chat/consumers.py index 73ecc6d..e057560 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -1,8 +1,11 @@ -from channels.generic.websocket import AsyncWebsocketConsumer +import json + from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer from django.contrib.auth import get_user_model + from .models import Chat, ChatParticipant, Message -import json +from .services import assert_can_send_message User = get_user_model() @@ -44,7 +47,14 @@ async def receive(self, text_data): content = data.get("message", "").strip() if content: - message = await self.create_message(content) + message, error = await self.create_message(content) + if error: + await self.send( + text_data=json.dumps({"type": "error", "detail": error}) + ) + return + if message is None: + return await self.channel_layer.group_send( self.room_group_name, { @@ -71,8 +81,25 @@ def user_can_access_chat(self): @database_sync_to_async def create_message(self, content): + """Create a persisted message, enforcing DM block rules. + + Returns: + tuple: ``(Message | None, str | None)`` — message instance and optional + error detail when creation is denied or fails validation. + """ chat = Chat.objects.get(id=self.chat_id) - return Message.objects.create(sender=self.user, chat=chat, content=content) + try: + assert_can_send_message(chat, self.user) + except PermissionError as exc: + return None, str(exc) + + content = (content or "").strip() + if not content: + return None, "Message cannot be empty." + if len(content) > 10000: + return None, "Message exceeds maximum length." + + return Message.objects.create(sender=self.user, chat=chat, content=content), None @database_sync_to_async def get_last_messages(self, limit=50): diff --git a/chat/forms.py b/chat/forms.py new file mode 100644 index 0000000..6356da8 --- /dev/null +++ b/chat/forms.py @@ -0,0 +1,35 @@ +"""Forms for server-rendered chat flows (mirroring REST validation rules).""" + +from django import forms + + +class StartDirectChatForm(forms.Form): + """Identify another user to open or reuse a 1-on-1 chat.""" + + other_username = forms.CharField( + max_length=150, + label="Other user's username", + help_text="Case-insensitive match to an existing account.", + ) + + def clean_other_username(self): + """Return stripped username for lookup.""" + return (self.cleaned_data.get("other_username") or "").strip() + + +class ChatMessageForm(forms.Form): + """Post a new message in a chat room (HTTP form, same limits as the API).""" + + content = forms.CharField( + max_length=10000, + widget=forms.Textarea( + attrs={"rows": 3, "placeholder": "Type a message…", "class": "chat-input"} + ), + ) + + def clean_content(self): + """Strip whitespace; reject empty after strip.""" + value = (self.cleaned_data.get("content") or "").strip() + if not value: + raise forms.ValidationError("Message cannot be empty.") + return value diff --git a/chat/migrations/0007_chat_is_global_dm_pair_key.py b/chat/migrations/0007_chat_is_global_dm_pair_key.py new file mode 100644 index 0000000..cb70013 --- /dev/null +++ b/chat/migrations/0007_chat_is_global_dm_pair_key.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chat", "0006_remove_message_chat_messag_lobby_i_96d6b6_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="chat", + name="is_global", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="chat", + name="dm_pair_key", + field=models.CharField( + blank=True, + help_text="Stable identifier for a 1-on-1 chat (two UUIDs, sorted).", + max_length=73, + null=True, + unique=True, + ), + ), + migrations.AddIndex( + model_name="chat", + index=models.Index( + fields=["is_global", "created_at"], + name="chat_chat_global_created_idx", + ), + ), + ] diff --git a/chat/migrations/0008_rename_chat_chat_global_created_idx_chat_chat_is_glob_a0825e_idx.py b/chat/migrations/0008_rename_chat_chat_global_created_idx_chat_chat_is_glob_a0825e_idx.py new file mode 100644 index 0000000..7f2426e --- /dev/null +++ b/chat/migrations/0008_rename_chat_chat_global_created_idx_chat_chat_is_glob_a0825e_idx.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2026-05-02 09:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0007_chat_is_global_dm_pair_key'), + ] + + operations = [ + migrations.RenameIndex( + model_name='chat', + new_name='chat_chat_is_glob_a0825e_idx', + old_name='chat_chat_global_created_idx', + ), + ] diff --git a/chat/models.py b/chat/models.py index 414401b..9dd1116 100644 --- a/chat/models.py +++ b/chat/models.py @@ -11,14 +11,73 @@ """ import uuid -from django.db import models -from django.utils import timezone + from django.contrib.auth import get_user_model -from django.db import transaction +from django.db import IntegrityError, models, transaction +from django.utils import timezone User = get_user_model() +class ChatManager(models.Manager): + """Custom manager with helpers for direct (1-on-1) chats.""" + + def get_or_create_direct(self, user_a, user_b): + """Return an existing or new direct message chat between two users. + + ``dm_pair_key`` enforces at most one Chat row per unordered user pair. + + Args: + user_a (User): First participant. + user_b (User): Second participant. + + Returns: + tuple[Chat, bool]: The chat instance and True if it was created. + + Raises: + ValueError: If both arguments refer to the same user. + """ + if user_a.pk == user_b.pk: + raise ValueError("Cannot create a direct chat with the same user.") + + key = dm_pair_key(user_a, user_b) + existing = self.filter(dm_pair_key=key).first() + if existing: + return existing, False + + try: + with transaction.atomic(): + chat = self.create( + name="", + is_group=False, + is_lobby=False, + is_global=False, + dm_pair_key=key, + ) + chat.add_participant(user_a) + chat.add_participant(user_b) + return chat, True + except IntegrityError: + recovered = self.filter(dm_pair_key=key).first() + if recovered: + return recovered, False + raise + + +def dm_pair_key(user_a, user_b): + """Build a stable unique key for an unordered pair of users. + + Args: + user_a (User): First user. + user_b (User): Second user. + + Returns: + str: Two UUIDs in lexicographic order, separated by ``':'``. + """ + a, b = sorted([str(user_a.pk), str(user_b.pk)]) + return f"{a}:{b}" + + class Chat(models.Model): """Represents a chat room (private, group, or lobby). @@ -35,16 +94,21 @@ class Chat(models.Model): description (str): Optional description. is_group (bool): Whether the chat supports multiple participants. is_lobby (bool): Whether the chat belongs to a game lobby. + is_global (bool): Whether this is a global/world channel (not lobby-bound). lobby (ForeignKey): Optional reference to a Lobby object. + dm_pair_key (str): For 1-on-1 chats only; stable key for the user pair. created_at (datetime): Timestamp of creation. """ + objects = ChatManager() + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=100, blank=True) description = models.TextField(blank=True) is_group = models.BooleanField(default=False) is_lobby = models.BooleanField(default=False) + is_global = models.BooleanField(default=False) lobby = models.ForeignKey( "game.Lobby", @@ -54,6 +118,14 @@ class Chat(models.Model): related_name="chat" ) + dm_pair_key = models.CharField( + max_length=73, + null=True, + blank=True, + unique=True, + help_text="Stable identifier for a 1-on-1 chat (two UUIDs, sorted).", + ) + created_at = models.DateTimeField(default=timezone.now) class Meta: @@ -62,6 +134,7 @@ class Meta: indexes = [ models.Index(fields=["is_group", "created_at"]), models.Index(fields=["name"]), + models.Index(fields=["is_global", "created_at"]), ] def __str__(self): @@ -130,6 +203,27 @@ def get_admins(self): chat=self, role__in=["admin", "owner"] ).select_related("user") + def is_direct_message(self): + """Return True if this chat is a private 1-on-1 (not lobby/group/global).""" + return ( + not self.is_group + and not self.is_lobby + and not self.is_global + ) + + def get_other_participant(self, user): + """Return the other user in a direct chat. + + Args: + user (User): One of the two participants. + + Returns: + User | None: The counterpart, or None if not applicable. + """ + if not self.is_direct_message(): + return None + return self.get_participants().exclude(pk=user.pk).first() + class ChatParticipant(models.Model): """Represents a user's membership in a chat with assigned permissions. @@ -221,17 +315,21 @@ def is_lobby_message(self): def is_private(self): """Determine if this is a private 1-on-1 message.""" - return not self.chat.is_group and not self.chat.is_lobby + return self.chat.is_direct_message() def get_chat_context(self): """Return structured information about the chat type. Returns: - dict: {type: 'private'|'group'|'lobby', name: str} + dict: ``type`` is one of ``private``, ``group``, ``lobby``, ``global``; + ``name`` is a short display label. """ if self.chat.is_lobby: return {"type": "lobby", "name": self.chat.name or "Lobby"} + if self.chat.is_global: + return {"type": "global", "name": self.chat.name or "Global"} + if self.chat.is_group: return {"type": "group", "name": self.chat.name or "Group Chat"} diff --git a/chat/serializers.py b/chat/serializers.py index 896146d..1d8e1ea 100644 --- a/chat/serializers.py +++ b/chat/serializers.py @@ -1,16 +1,80 @@ +"""Serializers for REST APIs in the chat application.""" + from rest_framework import serializers -from .models import Message, Chat + +from .models import Chat, Message class MessageSerializer(serializers.ModelSerializer): - sender = serializers.CharField(source='sender.username', read_only=True) + """Serialize stored chat messages for read APIs.""" + + sender = serializers.CharField(source="sender.username", read_only=True) + + class Meta: + model = Message + fields = ["id", "sender", "content", "sent_at"] + + +class MessageCreateSerializer(serializers.ModelSerializer): + """Validate inbound message bodies for create endpoints.""" class Meta: model = Message - fields = ['id', 'sender', 'content', 'sent_at'] + fields = ["content"] + + def validate_content(self, value): + """Strip whitespace and enforce non-empty, bounded length.""" + value = (value or "").strip() + if not value: + raise serializers.ValidationError("Message cannot be empty.") + if len(value) > 10000: + raise serializers.ValidationError("Message exceeds maximum length.") + return value class ChatSerializer(serializers.ModelSerializer): + """Serialize chat metadata for listing and direct-chat creation responses.""" + + peer_username = serializers.SerializerMethodField() + peer_id = serializers.SerializerMethodField() + class Meta: model = Chat - fields = ['id', 'name', 'is_group', 'is_lobby', 'lobby'] + fields = [ + "id", + "name", + "is_group", + "is_lobby", + "is_global", + "lobby", + "dm_pair_key", + "peer_username", + "peer_id", + ] + read_only_fields = ["dm_pair_key"] + + def get_peer_username(self, obj): + """Return the other user's username in a DM for the current viewer.""" + request = self.context.get("request") + if not request or not getattr(request.user, "is_authenticated", False): + return None + if not obj.is_direct_message(): + return None + other = obj.get_other_participant(request.user) + return other.username if other else None + + def get_peer_id(self, obj): + """Return the other user's id in a DM for the current viewer.""" + request = self.context.get("request") + if not request or not getattr(request.user, "is_authenticated", False): + return None + if not obj.is_direct_message(): + return None + other = obj.get_other_participant(request.user) + return str(other.pk) if other else None + + +class DirectChatCreateSerializer(serializers.Serializer): + """Request body for opening or retrieving a 1-on-1 chat.""" + + other_user_id = serializers.UUIDField() diff --git a/chat/services.py b/chat/services.py new file mode 100644 index 0000000..1826f21 --- /dev/null +++ b/chat/services.py @@ -0,0 +1,47 @@ +"""Messaging rules and permission checks for the chat application. + +This module centralizes direct-message blocking using ``accounts.Block`` +and validation before messages are stored or broadcast. +""" + +from accounts.models import Block + + +def is_direct_message_blocked(sender, recipient): + """Return True if ``recipient`` has blocked ``sender`` (sender cannot DM). + + Args: + sender: User attempting to send a message. + recipient: The other party in a direct chat. + + Returns: + bool: True when a block prevents ``sender`` from messaging ``recipient``. + """ + if sender.pk == recipient.pk: + return False + return Block.objects.filter(blocker=recipient, blocked=sender).exists() + + +def assert_can_send_message(chat, sender): + """Raise ``PermissionError`` if ``sender`` may not post in ``chat``. + + Lobby and global channels ignore pairwise blocks so existing behaviour stays + unchanged. + + Args: + chat: Target ``Chat`` instance. + sender: Authenticated user posting the message. + + Raises: + PermissionError: If the direct counterpart has blocked the sender or the + chat configuration is invalid for DM. + """ + if not chat.is_direct_message(): + return + + other = chat.get_other_participant(sender) + if other is None: + raise PermissionError("Direct chat has no valid counterpart.") + + if is_direct_message_blocked(sender, other): + raise PermissionError("You cannot send messages to this user.") diff --git a/chat/templates/chat.html b/chat/templates/chat.html deleted file mode 100644 index a810fbe..0000000 --- a/chat/templates/chat.html +++ /dev/null @@ -1,24 +0,0 @@ -
- -
- - -
- - diff --git a/chat/templates/chat/inbox.html b/chat/templates/chat/inbox.html new file mode 100644 index 0000000..d2286a8 --- /dev/null +++ b/chat/templates/chat/inbox.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Chats · Fools Arena{% endblock %} + +{% block content %} +

Your chats

+

Start a direct message

+ +{% if chat_rows %} +
    + {% for row in chat_rows %} +
  • + {{ row.title }} + ({{ row.chat.pk }}) +
  • + {% endfor %} +
+{% else %} +

No chats yet. Open a direct conversation.

+{% endif %} +{% endblock %} diff --git a/chat/templates/chat/room.html b/chat/templates/chat/room.html new file mode 100644 index 0000000..6e2ae15 --- /dev/null +++ b/chat/templates/chat/room.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} · Fools Arena{% endblock %} + +{% block content %} +

{{ title }}

+

← Inbox

+ +
+ {% for m in messages_list %} +
{{ m.sender.username }}: + {{ m.content }} + {{ m.sent_at|date:"Y-m-d H:i" }} +
+ {% empty %} +

No messages yet.

+ {% endfor %} +
+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ +

Live (WebSocket)

+

Optional: connect for live updates (same channel as the API-backed clients).

+
+ + +
+ + +{% endblock %} diff --git a/chat/templates/chat/start_direct.html b/chat/templates/chat/start_direct.html new file mode 100644 index 0000000..3789cc0 --- /dev/null +++ b/chat/templates/chat/start_direct.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}New direct chat · Fools Arena{% endblock %} + +{% block content %} +

Open or continue a direct chat

+

Enter the other player's username (same account as used to log in).

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+

Back to inbox

+{% endblock %} diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index bc88d34..0512110 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -117,3 +117,22 @@ def test_promote_owner_does_nothing(self, test_user): participant.demote() participant.refresh_from_db() assert participant.role == "owner" + + +@pytest.mark.django_db +class TestDirectChatManager: + """Tests for :meth:`Chat.objects.get_or_create_direct`.""" + + def test_creates_single_chat_per_pair(self, test_user, second_user): + """Two calls yield one chat row and ``dm_pair_key`` uniqueness.""" + c1, created1 = Chat.objects.get_or_create_direct(test_user, second_user) + c2, created2 = Chat.objects.get_or_create_direct(second_user, test_user) + assert created1 is True + assert created2 is False + assert c1.pk == c2.pk + assert Chat.objects.filter(dm_pair_key=c1.dm_pair_key).count() == 1 + + def test_same_user_raises(self, test_user): + """Starting a DM with yourself raises ``ValueError``.""" + with pytest.raises(ValueError): + Chat.objects.get_or_create_direct(test_user, test_user) diff --git a/chat/tests/test_chat_template_views.py b/chat/tests/test_chat_template_views.py new file mode 100644 index 0000000..94448f7 --- /dev/null +++ b/chat/tests/test_chat_template_views.py @@ -0,0 +1,76 @@ +"""Tests for server-rendered chat pages (template parity with the API).""" + +import pytest +from django.urls import reverse + +from accounts.models import Block +from chat.models import Chat, Message + + +@pytest.mark.django_db +class TestChatTemplateViews: + """Login-protected template flows: inbox, start DM, room + post.""" + + def test_inbox_redirects_anonymous(self, client): + """Inbox requires login.""" + r = client.get(reverse("chat:inbox")) + assert r.status_code == 302 + assert "/accounts/login/" in r.url + + def test_inbox_lists_chats(self, client, test_user, second_user): + """Authenticated user sees direct chat in the list.""" + client.force_login(test_user) + chat, _ = Chat.objects.get_or_create_direct(test_user, second_user) + r = client.get(reverse("chat:inbox")) + assert r.status_code == 200 + assert str(chat.pk) in r.content.decode() + + def test_start_direct_opens_room(self, client, test_user, second_user): + """Posting username redirects to the shared DM room.""" + client.force_login(test_user) + r = client.post( + reverse("chat:start_direct"), + {"other_username": second_user.username}, + ) + chat, _ = Chat.objects.get_or_create_direct(test_user, second_user) + assert r.status_code == 302 + assert r.url == reverse("chat:room", kwargs={"chat_id": chat.pk}) + + def test_room_post_creates_message(self, client, test_user, second_user): + """HTTP form post stores a message like the API.""" + client.force_login(test_user) + chat, _ = Chat.objects.get_or_create_direct(test_user, second_user) + r = client.post( + reverse("chat:room", kwargs={"chat_id": chat.pk}), + {"content": " template hi "}, + ) + assert r.status_code == 302 + assert Message.objects.filter( + chat=chat, sender=test_user, content="template hi" + ).exists() + + def test_room_blocked_post_fails(self, client, test_user, second_user): + """Template path shows error and does not store when blocked.""" + chat, _ = Chat.objects.get_or_create_direct(test_user, second_user) + Block.objects.create(blocker=second_user, blocked=test_user) + client.force_login(test_user) + r = client.post( + reverse("chat:room", kwargs={"chat_id": chat.pk}), + {"content": "blocked"}, + ) + assert r.status_code == 200 + assert not Message.objects.filter( + chat=chat, content="blocked" + ).exists() + + def test_room_forbidden_for_non_member(self, client, test_user, user_factory): + """Non-participant is redirected to inbox with a message.""" + other = user_factory(username="onlymember") + chat = Chat.objects.create(name="X", is_group=True) + chat.add_participant(other) + client.force_login(test_user) + r = client.get( + reverse("chat:room", kwargs={"chat_id": chat.pk}), + ) + assert r.status_code == 302 + assert r.url == reverse("chat:inbox") diff --git a/chat/tests/test_private_messaging_api.py b/chat/tests/test_private_messaging_api.py new file mode 100644 index 0000000..f9de7df --- /dev/null +++ b/chat/tests/test_private_messaging_api.py @@ -0,0 +1,115 @@ +"""Tests for private DM REST endpoints and blocking rules.""" + +import pytest +from django.urls import reverse +from rest_framework import status + +from accounts.models import Block +from chat.models import Chat, Message + + +@pytest.mark.django_db +class TestPrivateMessagingAPI: + """End-to-end API tests for direct chats and message posts.""" + + def _login(self, api_client, user): + """Attach a session for the given user.""" + api_client.force_login(user) + + def test_direct_chat_create_and_idempotent( + self, api_client, test_user, second_user + ): + """POST /chats/direct/ returns one chat; repeat returns the same id.""" + self._login(api_client, test_user) + url = reverse("chat-direct-create") + r1 = api_client.post( + url, + {"other_user_id": str(second_user.pk)}, + format="json", + ) + assert r1.status_code in (200, 201) + chat_id = r1.data["id"] + r2 = api_client.post( + url, + {"other_user_id": str(second_user.pk)}, + format="json", + ) + assert r2.status_code in (200, 201) + assert r2.data["id"] == chat_id + assert Chat.objects.filter(dm_pair_key__isnull=False).count() == 1 + + def test_direct_chat_self_rejected(self, api_client, test_user): + """Cannot open a DM with yourself.""" + self._login(api_client, test_user) + url = reverse("chat-direct-create") + r = api_client.post( + url, + {"other_user_id": str(test_user.pk)}, + format="json", + ) + assert r.status_code == status.HTTP_400_BAD_REQUEST + + def test_post_message_in_dm(self, api_client, test_user, second_user): + """Messages persist and appear in list for both participants.""" + self._login(api_client, test_user) + durl = reverse("chat-direct-create") + r = api_client.post( + durl, + {"other_user_id": str(second_user.pk)}, + format="json", + ) + chat_id = r.data["id"] + murl = reverse("chat-messages", kwargs={"chat_id": chat_id}) + pr = api_client.post(murl, {"content": " hello "}, format="json") + assert pr.status_code == status.HTTP_201_CREATED + self._login(api_client, second_user) + gr = api_client.get(murl) + assert gr.status_code == status.HTTP_200_OK + assert any(m["content"] == "hello" for m in gr.data) + + def test_blocked_user_cannot_post_dm( + self, api_client, test_user, second_user + ): + """If second_user blocks test_user, test_user cannot post in the DM.""" + self._login(api_client, test_user) + durl = reverse("chat-direct-create") + r = api_client.post( + durl, + {"other_user_id": str(second_user.pk)}, + format="json", + ) + chat_id = r.data["id"] + murl = reverse("chat-messages", kwargs={"chat_id": chat_id}) + Block.objects.create(blocker=second_user, blocked=test_user) + pr = api_client.post(murl, {"content": "nope"}, format="json") + assert pr.status_code == status.HTTP_403_FORBIDDEN + assert Message.objects.filter(chat_id=chat_id, content="nope").count() == 0 + + def test_lobby_message_ignores_block( + self, api_client, test_user, second_user, basic_lobby + ): + """Pairwise blocks do not block lobby channel messages (API layer).""" + lobby_chat = Chat.objects.create( + name="Lobby", + is_lobby=True, + is_group=True, + lobby=basic_lobby, + ) + lobby_chat.add_participant(test_user) + lobby_chat.add_participant(second_user) + Block.objects.create(blocker=second_user, blocked=test_user) + self._login(api_client, test_user) + murl = reverse("chat-messages", kwargs={"chat_id": str(lobby_chat.pk)}) + pr = api_client.post(murl, {"content": "lobby ok"}, format="json") + assert pr.status_code == status.HTTP_201_CREATED + assert Message.objects.filter(chat=lobby_chat, content="lobby ok").exists() + + def test_non_participant_cannot_post(self, api_client, test_user, user_factory): + """User who is not in the chat gets 403 from membership check.""" + outsider = user_factory(username="outsider99") + chat = Chat.objects.create(name="G", is_group=True) + chat.add_participant(test_user) + self._login(api_client, outsider) + murl = reverse("chat-messages", kwargs={"chat_id": str(chat.pk)}) + pr = api_client.post(murl, {"content": "x"}, format="json") + assert pr.status_code == status.HTTP_403_FORBIDDEN diff --git a/chat/urls.py b/chat/urls.py index d38f4a6..ae2fdca 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -1,6 +1,18 @@ +""" +Template routes for the chat app (included under ``/chat/`` in the project URLs). + +These mirror the REST API: inbox listing, starting a direct chat, and the room +with message history plus posting. +""" + from django.urls import path -from .views import chat_view + +from .views import chat_inbox, chat_room, start_direct_chat + +app_name = "chat" urlpatterns = [ - path('chat/', chat_view, name='chat'), + path("", chat_inbox, name="inbox"), + path("direct/new/", start_direct_chat, name="start_direct"), + path("/", chat_room, name="room"), ] diff --git a/chat/views.py b/chat/views.py index 3c53fe0..8322153 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,5 +1,173 @@ -from django.shortcuts import render +"""Server-rendered views for chat (same behaviour as the REST API layer).""" +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect, render -def chat_view(request): - return render(request, "chat.html") +from .forms import ChatMessageForm, StartDirectChatForm +from .models import Chat, Message +from .services import assert_can_send_message + +User = get_user_model() + + +def _chat_display_title(chat, viewer): + """Return a short title for a chat row or room header. + + Args: + chat (Chat): Chat instance. + viewer: Current user. + + Returns: + str: Human-readable title. + """ + if chat.is_lobby: + return chat.name or "Lobby chat" + if chat.is_global: + return chat.name or "Global chat" + if chat.is_direct_message(): + other = chat.get_other_participant(viewer) + if other: + return f"DM — {other.get_username()}" + return "Direct message" + if chat.is_group: + return chat.name or "Group chat" + return chat.name or str(chat.pk) + + +@login_required +def chat_inbox(request): + """Render the chat inbox for the signed-in user. + + Uses the same participant filter as the JSON list endpoint so template and + API stay in sync. + + Args: + request (HttpRequest): Current request; ``request.user`` must be + authenticated (enforced by ``@login_required``). + + Returns: + HttpResponse: Rendered ``chat/inbox.html`` with ``chat_rows``. + """ + chat_list = ( + Chat.objects.filter(chatparticipant__user=request.user) + .distinct() + .order_by("-created_at") + ) + rows = [ + { + "chat": c, + "title": _chat_display_title(c, request.user), + } + for c in chat_list + ] + return render( + request, + "chat/inbox.html", + { + "chat_rows": rows, + }, + ) + + +@login_required +def start_direct_chat(request): + """Show the \"start direct chat\" form or process username submission. + + On success, redirects to the shared :func:`chat_room` for that DM. + + Args: + request (HttpRequest): GET shows the form; POST expects + ``other_username``. + + Returns: + HttpResponse: Form page or redirect to the DM room. + """ + if request.method == "POST": + form = StartDirectChatForm(request.POST) + if form.is_valid(): + name = form.cleaned_data["other_username"] + if not name: + messages.error(request, "Enter a username.") + return render( + request, + "chat/start_direct.html", + {"form": StartDirectChatForm()}, + ) + + other = User.objects.filter(username__iexact=name).first() + if other is None: + messages.error(request, "No user with that username.") + return render( + request, + "chat/start_direct.html", + {"form": form}, + ) + + if other.pk == request.user.pk: + messages.error(request, "You cannot open a direct chat with yourself.") + return render( + request, + "chat/start_direct.html", + {"form": StartDirectChatForm()}, + ) + + chat, _created = Chat.objects.get_or_create_direct(request.user, other) + return redirect("chat:room", chat_id=chat.pk) + + form = StartDirectChatForm() + return render(request, "chat/start_direct.html", {"form": form}) + + +@login_required +def chat_room(request, chat_id): + """Display chronological messages and accept HTTP POST for new lines. + + Live updates use the same Channels consumer as API/WebSocket clients. + + Args: + request (HttpRequest): GET loads history; POST validates and saves. + chat_id (uuid.UUID): Primary key of the :class:`~chat.models.Chat`. + + Returns: + HttpResponse: Room template, redirect after successful send, or redirect + away if the user is not a participant. + """ + chat = get_object_or_404(Chat, pk=chat_id) + if not chat.has_participant(request.user): + messages.error(request, "You are not a member of this chat.") + return redirect("chat:inbox") + + if request.method == "POST": + form = ChatMessageForm(request.POST) + if form.is_valid(): + try: + assert_can_send_message(chat, request.user) + except PermissionError as exc: + messages.error(request, str(exc)) + else: + Message.objects.create( + sender=request.user, + chat=chat, + content=form.cleaned_data["content"], + ) + return redirect("chat:room", chat_id=chat.pk) + else: + form = ChatMessageForm() + + msg_list = list( + Message.objects.filter(chat=chat).select_related("sender").order_by("sent_at") + ) + return render( + request, + "chat/room.html", + { + "chat": chat, + "title": _chat_display_title(chat, request.user), + "messages_list": msg_list, + "form": form, + "ws_scheme": "wss" if request.is_secure() else "ws", + "ws_host": request.get_host(), + }, + ) From 80b93368435886706d128c23f1833cbf4af7ae8e Mon Sep 17 00:00:00 2001 From: Maxim <96714576+Viton8@users.noreply.github.com> Date: Sat, 2 May 2026 14:04:43 +0300 Subject: [PATCH 12/12] Apply suggestions from code review Co-authored-by: Kiryl Alishkevich <64920776+uxabix@users.noreply.github.com> --- Fools_Arena/asgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Fools_Arena/asgi.py b/Fools_Arena/asgi.py index bd3956b..196b79a 100644 --- a/Fools_Arena/asgi.py +++ b/Fools_Arena/asgi.py @@ -1,7 +1,7 @@ """ ASGI config for Fools_Arena project. -It exposes the ASGI callable as a module-level variable namedя ``application``. +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.2/howto/deployment/asgi/