From ad2bcf4b2150ae78b4ea8fffb8bfde79d8171b16 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 8 Nov 2025 10:54:30 +0100 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/ From 8d3b846bcdc5c371782786cf2c818695d7e98b35 Mon Sep 17 00:00:00 2001 From: Viton8 Date: Sat, 2 May 2026 19:01:27 +0300 Subject: [PATCH 13/13] integrate Sphinx for auto-generated documentation - Install and configure Sphinx with Napoleon, autodoc, and viewcode extensions - Generate initial HTML documentation in /docs directory - Update README.md with build and rebuild instructions - Fix existing docstring formatting for compatibility - Add Sphinx-generated files to .gitignore - Ensure documentation covers models and views --- .gitignore | 5 + README.md | 44 +++++ accounts/models.py | 50 +----- chat/models.py | 31 +--- docs/Makefile | 15 ++ docs/_static/.gitkeep | 0 docs/conf.py | 38 +++++ docs/index.rst | 18 ++ docs/models.rst | 8 + docs/views.rst | 12 ++ game/models.py | 374 ------------------------------------------ requirements.txt | Bin 1506 -> 1424 bytes 12 files changed, 149 insertions(+), 446 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/_static/.gitkeep create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/models.rst create mode 100644 docs/views.rst diff --git a/.gitignore b/.gitignore index a97294d..2612161 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# ========================= +# Sphinx documentation +# ========================= +docs/_build/ + # ========================= # Python / general # ========================= diff --git a/README.md b/README.md index f7c8cab..9326382 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,50 @@ docker compose down ``` --- +## Documentation + +Developer documentation is built with [Sphinx](https://www.sphinx-doc.org/) from the [`docs/`](docs/) directory (autodoc, Google-style docstrings via Napoleon, viewcode). + +**Install dependencies** (includes Sphinx): + +```bash +pip install -r requirements.txt +``` + +**Generate HTML** (either command from the repository root): + +```bash +cd docs && make html +``` + +```bash +sphinx-build -b html docs docs/_build +``` + +Open `docs/_build/html/index.html` in a browser. + +**Rebuild after code or docstring changes** (clean output, then build again): + +```bash +cd docs && make clean html +``` + +Or remove the build directory and run `sphinx-build` again: + +```bash +rm -rf docs/_build && sphinx-build -b html docs docs/_build +``` + +**Using Docker** (from the project root, with the stack running and the app image built): + +```bash +docker compose exec web sphinx-build -b html docs docs/_build +``` + +The `docs/_build/` directory is gitignored. + +--- + ## 🚀 Stack - Django, REST, Channels - Redis diff --git a/accounts/models.py b/accounts/models.py index e3dfa12..2d3c425 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -13,38 +13,11 @@ class User(AbstractUser): """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. - 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: - user = User.objects.create_user( - username="player1", - email="player1@example.com", - password="secure_password" - ) - user.avatar_url = "https://example.com/avatar.jpg" - user.save() + Extends Django ``AbstractUser`` with game-specific fields and helpers. + Uses a UUID primary key. Inherits standard auth fields from ``AbstractUser`` + (username, email, password, ``is_active``, ``is_staff``, etc.). + Reverse relations include lobby membership, game participation, messages, + hands, and turns. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -204,18 +177,7 @@ class Block(models.Model): 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) + The pair ``(blocker, blocked)`` is unique and indexed for lookups. """ blocker = models.ForeignKey( diff --git a/chat/models.py b/chat/models.py index 9dd1116..93ecccb 100644 --- a/chat/models.py +++ b/chat/models.py @@ -87,17 +87,6 @@ class Chat(models.Model): - 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. - 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() @@ -229,12 +218,6 @@ class ChatParticipant(models.Model): """Represents a user's membership in a chat with assigned permissions. Each user can belong to multiple chats and have different roles in each. - - 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. """ ROLE_CHOICES = [ @@ -282,20 +265,12 @@ def demote(self): self.role = "member" self.save(update_fields=["role"]) - class Message(models.Model): """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 (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. + Messages belong strictly to a ``Chat`` instance. Lobby and direct messages + differ by chat type only; there are no separate lobby or receiver fields + on this model. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..f423a41 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,15 @@ +# Minimal makefile for Sphinx documentation +# + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +.PHONY: help Makefile + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..22a8dbc --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,38 @@ +# pylint: skip-file +"""Sphinx configuration for Fools_Arena.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.settings") + +import django + +django.setup() + +project = "Fools_Arena" +copyright = "2026, Maksim Bayarchuk, Kiryl Alishkevich, Aliaksandr Saroka" +author = "Maksim Bayarchuk, Kiryl Alishkevich, Aliaksandr Saroka" +release = "0.1" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +napoleon_google_docstring = True +napoleon_numpy_docstring = False + +html_theme = "alabaster" +html_static_path = ["_static"] + +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..22f1bc6 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +Fools_Arena developer documentation +=================================== + +Online multiplayer **Durak** (Fool) card game — Django and Django Channels. + +.. toctree:: + :maxdepth: 2 + :caption: Reference + + models + views + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/models.rst b/docs/models.rst new file mode 100644 index 0000000..cca3705 --- /dev/null +++ b/docs/models.rst @@ -0,0 +1,8 @@ +Models +====== + +.. automodule:: accounts.models + +.. automodule:: chat.models + +.. automodule:: game.models diff --git a/docs/views.rst b/docs/views.rst new file mode 100644 index 0000000..918bb1a --- /dev/null +++ b/docs/views.rst @@ -0,0 +1,12 @@ +Views +===== + +.. automodule:: accounts.views + +.. automodule:: accounts.api_views + +.. automodule:: chat.views + +.. automodule:: chat.api_views + +.. automodule:: game.views diff --git a/game/models.py b/game/models.py index e4345de..2133fdd 100644 --- a/game/models.py +++ b/game/models.py @@ -8,28 +8,12 @@ import uuid from django.db import models - class CardSuit(models.Model): """Card suit model representing playing card suits (Hearts, Diamonds, etc.). Defines the four traditional card suits with their display names and colors. Used as a lookup table for card generation and game logic. - Attributes: - id (SmallAutoField): Primary key with small integer for efficiency. - name (CharField): Display name of the suit (e.g., "Hearts", "Spades"). - color (CharField): Color of the suit, either "red" or "black". - - Color Choices: - - 'red': For Hearts and Diamonds - - 'black': For Clubs and Spades - - Example: - # Create a heart suit - hearts = CardSuit.objects.create( - name="Hearts", - color="red" - ) """ id = models.SmallAutoField(primary_key=True) @@ -57,24 +41,12 @@ class Meta: verbose_name_plural = 'Card Suits' ordering = ['name'] - class CardRank(models.Model): """Card rank model representing playing card values (Ace, King, etc.). Defines the ranks/values of playing cards with their display names and numeric values for comparison during gameplay. - Attributes: - id (SmallAutoField): Primary key with small integer for efficiency. - name (CharField): Display name of the rank (e.g., "Ace", "King"). - value (IntegerField): Numeric value used for card comparison and game logic. - - Example: - # Create an Ace card rank - ace = CardRank.objects.create( - name="Ace", - value=14 # Highest value in most variations - ) """ id = models.SmallAutoField(primary_key=True) @@ -102,7 +74,6 @@ class Meta: verbose_name_plural = 'Card Ranks' ordering = ['value'] - class Lobby(models.Model): """Game lobby model for organizing players before starting games. @@ -110,34 +81,6 @@ class Lobby(models.Model): to start a Durak game session. Handles lobby ownership, privacy settings, and player management. - Attributes: - id (UUIDField): Unique identifier for the lobby. - owner (ForeignKey): Reference to the User who created the lobby. - name (CharField): Display name of the lobby. - is_private (BooleanField): Whether the lobby requires a password to join. - password_hash (CharField, optional): Hashed password for private lobbies. - status (CharField): Current lobby state. - created_at (DateTimeField): When the lobby was created. - - Related Objects: - settings: LobbySettings object with game configuration (OneToOne). - players: LobbyPlayer objects representing users in this lobby. - games: Game objects that have been played in this lobby. - messages: Chat messages sent in this lobby. - - Status Choices: - - 'waiting': Lobby is open and waiting for players - - 'playing': Game is currently in progress - - 'closed': Lobby has been closed and is no longer accessible - - Example: - # Create a public lobby - lobby = Lobby.objects.create( - owner=user, - name="Beginner's Game", - is_private=False, - status='waiting' - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -190,7 +133,6 @@ class Meta: verbose_name_plural = 'Lobbies' ordering = ['-created_at'] - class LobbySettings(models.Model): """Configuration settings for a game lobby. @@ -198,32 +140,6 @@ class LobbySettings(models.Model): started within the associated lobby. Each lobby has exactly one settings configuration. - Attributes: - id (UUIDField): Unique identifier for the settings. - lobby (OneToOneField): Reference to the associated Lobby. - max_players (PositiveIntegerField): Maximum number of players allowed. - card_count (IntegerField): Number of cards to use in the deck. - is_transferable (BooleanField): Whether cards can be transferred between players. - neighbor_throw_only (BooleanField): Whether only neighbors can throw in additional cards. - allow_jokers (BooleanField): Whether joker cards are included in the deck. - turn_time_limit (IntegerField, optional): Time limit per turn in seconds. - special_rule_set (ForeignKey, optional): Reference to a special rule configuration. - - Card Count Choices: - - 24: Short deck (9, 10, J, Q, K, A of each suit) - - 36: Standard deck (6, 7, 8, 9, 10, J, Q, K, A of each suit) - - 52: Full deck (all cards including 2-5) - - Example: - # Create standard game settings - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36, - is_transferable=True, - neighbor_throw_only=False, - allow_jokers=False - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -269,7 +185,6 @@ class Meta: verbose_name = 'Lobby Settings' verbose_name_plural = 'Lobby Settings' - class LobbyPlayer(models.Model): """Relationship model connecting users to lobbies with their status. @@ -277,29 +192,6 @@ class LobbyPlayer(models.Model): current status and readiness to play. Handles the player lifecycle from joining to leaving the lobby. - Attributes: - id (UUIDField): Unique identifier for the lobby membership. - lobby (ForeignKey): Reference to the Lobby the player has joined. - user (ForeignKey): Reference to the User who joined the lobby. - status (CharField): Current status of the player in the lobby. - - Status Choices: - - 'waiting': Player has joined but is not ready to start - - 'ready': Player is ready to start a game - - 'playing': Player is currently in an active game - - 'left': Player has left the lobby - - Example: - # Add a player to a lobby - player = LobbyPlayer.objects.create( - lobby=lobby, - user=user, - status='waiting' - ) - - # Mark player as ready - player.status = 'ready' - player.save() """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -347,7 +239,6 @@ class Meta: unique_together = ['lobby', 'user'] ordering = ['lobby', 'user__username'] - class Game(models.Model): """Core game session model for Durak card game. @@ -355,40 +246,6 @@ class Game(models.Model): Handles game state, trump card selection, and player management. Each game is linked to a specific lobby and tracks all game-related data. - Attributes: - id (UUIDField): Unique identifier for the game session. - lobby (ForeignKey): Reference to the Lobby where this game is played. - trump_card (ForeignKey): The card that determines the trump suit for this game. - started_at (DateTimeField): When the game session began. - finished_at (DateTimeField, optional): When the game ended (null for active games). - status (CharField): Current game state ('in_progress' or 'finished'). - loser (ForeignKey, optional): Reference to the User who lost the game. - - Related Objects: - players: GamePlayer objects representing players in this game session. - deck_cards: GameDeck objects representing cards remaining in the deck. - hands: PlayerHand objects showing which cards each player holds. - table_cards: TableCard objects representing cards currently on the table. - discarded_cards: DiscardPile objects for cards that have been discarded. - turns: Turn objects tracking the sequence of player turns. - - Status Choices: - - 'in_progress': Game is currently being played - - 'finished': Game has ended with a winner/loser determined - - Example: - # Start a new game - game = Game.objects.create( - lobby=lobby, - trump_card=selected_trump_card, - status='in_progress' - ) - - # End the game with a loser - game.status = 'finished' - game.loser = losing_player - game.finished_at = timezone.now() - game.save() """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -446,28 +303,12 @@ class Meta: verbose_name_plural = 'Games' ordering = ['-started_at'] - class GamePlayer(models.Model): """Relationship model connecting users to game sessions with game-specific data. Represents a player's participation in a specific game, tracking their position, remaining cards, and other game-state information. - Attributes: - id (UUIDField): Unique identifier for the game participation. - game (ForeignKey): Reference to the Game session. - user (ForeignKey): Reference to the participating User. - seat_position (IntegerField): Player's position around the table (turn order). - cards_remaining (IntegerField): Number of cards currently in player's hand. - - Example: - # Add a player to a game - game_player = GamePlayer.objects.create( - game=game, - user=user, - seat_position=1, - cards_remaining=6 - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -514,7 +355,6 @@ class Meta: unique_together = ['game', 'user'] ordering = ['seat_position'] - class Card(models.Model): """Playing card model combining suit, rank, and optional special properties. @@ -522,30 +362,6 @@ class Card(models.Model): special abilities. Cards can be standard playing cards or special cards with unique effects. - Attributes: - id (UUIDField): Unique identifier for the card. - suit (ForeignKey): Reference to the CardSuit (Hearts, Diamonds, etc.). - rank (ForeignKey): Reference to the CardRank (Ace, King, etc.). - special_card (ForeignKey, optional): Reference to special card effects if applicable. - - Related Objects: - attack_card: TableCard objects where this card is the attacking card. - defense_card: TableCard objects where this card is the defending card. - as_trump: Game objects where this card serves as the trump card. - - Example: - # Create a standard playing card - card = Card.objects.create( - suit=hearts_suit, - rank=ace_rank - ) - - # Create a special card - special_card = Card.objects.create( - suit=spades_suit, - rank=joker_rank, - special_card=skip_effect - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -613,43 +429,12 @@ class Meta: verbose_name_plural = 'Cards' unique_together = ['suit', 'rank', 'special_card'] - class SpecialCard(models.Model): """Special card effects model for custom game mechanics. Defines special abilities that can be applied to cards to create unique gameplay mechanics beyond standard Durak rules. Each special card type has a specific effect and description. - - Attributes: - id (UUIDField): Unique identifier for the special card type. - name (CharField): Display name of the special effect. - effect_type (CharField): Category of the special effect. - effect_value (JSONField): JSON data containing effect parameters. - description (TextField): Human-readable description of the effect. - - Effect Types: - - 'skip': Skip the next player's turn - - 'reverse': Reverse turn order - - 'draw': Force target player to draw additional cards - - 'custom': Custom effect with parameters in effect_value - - Example: - # Create a skip effect card - skip_effect = SpecialCard.objects.create( - name="Skip Turn", - effect_type="skip", - effect_value={}, - description="Next player loses their turn" - ) - - # Create a draw effect card - draw_effect = SpecialCard.objects.create( - name="Draw Two", - effect_type="draw", - effect_value={"card_count": 2}, - description="Target player draws 2 additional cards" - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -705,38 +490,12 @@ class Meta: verbose_name_plural = 'Special Cards' ordering = ['name'] - class SpecialRuleSet(models.Model): """Special rule configuration for advanced game variants. Defines collections of special rules and cards that can be applied to lobbies to create custom game experiences. Each rule set can specify minimum player requirements and special card inclusions. - - Attributes: - id (UUIDField): Unique identifier for the rule set. - name (CharField): Display name of the rule set. - description (TextField): Detailed description of the rules. - min_players (IntegerField): Minimum players required for this rule set. - - Related Objects: - special_cards: SpecialCard objects included in this rule set (M2M through SpecialRuleSetCard). - lobby_settings: LobbySettings objects using this rule set. - - Example: - # Create a beginner-friendly rule set - beginner_rules = SpecialRuleSet.objects.create( - name="Beginner Special", - description="Simple special cards for new players", - min_players=2 - ) - - # Create an advanced rule set - advanced_rules = SpecialRuleSet.objects.create( - name="Master's Challenge", - description="Complex special effects for experienced players", - min_players=4 - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -799,34 +558,12 @@ class Meta: verbose_name_plural = 'Special Rule Sets' ordering = ['name'] - class SpecialRuleSetCard(models.Model): """Association model linking special cards to rule sets with configuration. Defines which special cards are included in specific rule sets and how they should be configured within that context. Allows for fine-tuned control over special card availability and behavior. - - Attributes: - id (UUIDField): Unique identifier for the association. - rule_set (ForeignKey): Reference to the SpecialRuleSet. - card (ForeignKey): Reference to the SpecialCard. - is_enabled (BooleanField): Whether this card is active in the rule set. - - Example: - # Add a special card to a rule set - SpecialRuleSetCard.objects.create( - rule_set=beginner_rules, - card=skip_effect, - is_enabled=True - ) - - # Add but disable a complex card for beginners - SpecialRuleSetCard.objects.create( - rule_set=beginner_rules, - card=complex_effect, - is_enabled=False - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -875,26 +612,12 @@ class Meta: unique_together = ['rule_set', 'card'] ordering = ['rule_set__name', 'card__name'] - class GameDeck(models.Model): """Model representing cards remaining in the deck during a game. Tracks the position and order of cards in the game deck. Cards are drawn from this deck when players need to replenish their hands. - Attributes: - id (UUIDField): Unique identifier for the deck entry. - game (ForeignKey): Reference to the Game session. - card (ForeignKey): Reference to the Card in the deck. - position (IntegerField): Position of the card in the deck (for draw order). - - Example: - # Add a card to the game deck - GameDeck.objects.create( - game=game, - card=card, - position=1 - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -956,28 +679,12 @@ class Meta: unique_together = ['game', 'position'] ordering = ['position'] - class PlayerHand(models.Model): """Model representing cards in a player's hand during a game. Tracks which cards each player holds, with optional ordering information for UI display purposes. - Attributes: - id (UUIDField): Unique identifier for the hand entry. - game (ForeignKey): Reference to the Game session. - player (ForeignKey): Reference to the User who holds the card. - card (ForeignKey): Reference to the Card in the player's hand. - order_in_hand (IntegerField, optional): Display order of card in hand. - - Example: - # Add a card to a player's hand - PlayerHand.objects.create( - game=game, - player=user, - card=card, - order_in_hand=1 - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -1040,7 +747,6 @@ class Meta: unique_together = ['game', 'player', 'card'] ordering = ['order_in_hand'] - class TableCard(models.Model): """Model representing attack and defense card pairs on the table. @@ -1048,25 +754,6 @@ class TableCard(models.Model): and can be defended by appropriate defending cards. This model tracks these attack-defense pairs. - Attributes: - id (UUIDField): Unique identifier for the table card pair. - game (ForeignKey): Reference to the Game session. - attack_card (ForeignKey): The card used for attack. - defense_card (ForeignKey, optional): The card used for defense (null if undefended). - - Related Objects: - moves: Move objects referencing this table card pair. - - Example: - # Place an attack card on the table - table_card = TableCard.objects.create( - game=game, - attack_card=seven_of_hearts - ) - - # Defend the attack - table_card.defense_card = ten_of_hearts - table_card.save() """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -1129,26 +816,12 @@ class Meta: verbose_name_plural = 'Table Cards' ordering = ['id'] - class DiscardPile(models.Model): """Model representing cards that have been discarded from the game. After successful defense rounds or when cards are played out, they are moved to the discard pile and removed from active play. - Attributes: - id (UUIDField): Unique identifier for the discard entry. - game (ForeignKey): Reference to the Game session. - card (ForeignKey): Reference to the discarded Card. - position (IntegerField, optional): Order in which cards were discarded. - - Example: - # Discard cards after a successful defense - DiscardPile.objects.create( - game=game, - card=attack_card, - position=1 - ) """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -1179,7 +852,6 @@ def discard_cards(cls, game, cards): last_position = cls.objects.filter(game=game).count() discard_entries = [] - class Turn(models.Model): """Turn tracking model for managing player turn sequence in games. @@ -1187,25 +859,6 @@ class Turn(models.Model): turn it is and maintaining the sequential order of gameplay. Each turn can contain multiple moves (attack, defend, pickup). - Attributes: - id (UUIDField): Unique identifier for the turn. - game (ForeignKey): Reference to the Game session this turn belongs to. - player (ForeignKey): Reference to the User whose turn it is. - turn_number (IntegerField): Sequential number of this turn in the game. - - Related Objects: - moves: Move objects that occurred during this turn. - - Example: - # Create a new turn - turn = Turn.objects.create( - game=game, - player=current_player, - turn_number=1 - ) - - # 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) @@ -1273,7 +926,6 @@ class Meta: unique_together = ['game', 'turn_number'] ordering = ['turn_number'] - class Move(models.Model): """Game move model representing individual player actions during gameplay. @@ -1281,32 +933,6 @@ class Move(models.Model): attacking with cards, defending attacks, or picking up cards. Each move is associated with a turn and references the relevant table cards. - Attributes: - id (UUIDField): Unique identifier for the move. - turn (ForeignKey): Reference to the Turn this move belongs to. - table_card (ForeignKey): Reference to the TableCard affected by this move. - action_type (CharField): Type of action performed (attack, defend, pickup). - created_at (DateTimeField): Timestamp when the move was made. - - Action Types: - - 'attack': Player places an attacking card on the table - - 'defend': Player defends an attack with an appropriate card - - 'pickup': Player picks up undefended cards from the table - - Example: - # Record an attack move - attack_move = Move.objects.create( - turn=current_turn, - table_card=table_card, - action_type='attack' - ) - - # Record a defense move - defense_move = Move.objects.create( - turn=current_turn, - table_card=table_card, - action_type='defend' - ) """ ACTION_CHOICES = [ diff --git a/requirements.txt b/requirements.txt index 9325eedb4f7ac790307e2eaba295ce46eafbd55c..76a5d9aba2ae8c470fad8cda13d14900ffe1f72a 100644 GIT binary patch delta 256 zcmXYrOG*Pl7=)`|ARQ2K;UhFdI}8%S5JnRX26W{iL|iD2&qWLh;xVSF6Bs>#H*k|9 zh-()vT)Gq&_6$-K|Kj_Ls*hIHs*>Pc*vmqhE?b=9Jn9O&QJd(Ys2v{H!gMjbwZnIt z^TM+b9UhAA(4xc%Cb+~HXH8}h|LfcaEcS67=@=KdY1r03+vcm-;jc*PafHch%xHc9(JUUDGnG7uO5; Zr2Bl=o+nKXv2CB+yP2_j)behj)r3K5^c+G0=(QCKls_*3`*7A8z#Wo<1M zDSQ+Q!50uKE5UPz$S~gw%(>saGi!b}XKN;#dry=d;&7d~YY~q*u_%0o6&&Flr{ozX zTs!Dt#N9_W|79wAvq|ooE?F76;(g+?DS7`ebVehT#+I;2oX9G9AX;@R`uZeo@=etC zE^7FZ0l8uIzGn-vvika9HUDBeZ5`|a*QjxVDfvIz7DpQ*i{GiryKefzB~!=z#5Co$ z)A&6Mm