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/asgi.py b/Fools_Arena/asgi.py index 36517c8..196b79a 100644 --- a/Fools_Arena/asgi.py +++ b/Fools_Arena/asgi.py @@ -9,15 +9,17 @@ import os +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": websocket_application, -}) \ No newline at end of file + "websocket": AuthMiddlewareStack(websocket_application), +}) diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index d6bc835..fcf1d39 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", @@ -121,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/Fools_Arena/urls.py b/Fools_Arena/urls.py index f8e673e..f5345e3 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -24,14 +24,14 @@ urlpatterns = [ path("admin/", admin.site.urls), -# UI - path('accounts/', include('accounts.urls')), - + # UI + 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")), + path("api/chat/", include("chat.api_urls")), ] # Add static files urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - diff --git a/README.md b/README.md index a55fa2a..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 @@ -53,7 +76,8 @@ docker compose down --- ## 🚀 Stack -- Django, REST, Channels +- Django, REST, Channels +- Redis - PostgreSQL - Docker - GitFlow 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/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}" 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/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 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 new file mode 100644 index 0000000..dd58ca9 --- /dev/null +++ b/chat/api_urls.py @@ -0,0 +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, DirectChatCreateAPIView + +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 new file mode 100644 index 0000000..cf0b8a9 --- /dev/null +++ b/chat/api_views.py @@ -0,0 +1,106 @@ +"""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 ( + ChatSerializer, + DirectChatCreateSerializer, + MessageCreateSerializer, + MessageSerializer, +) +from .services import assert_can_send_message + +User = get_user_model() + + +class ChatListAPIView(generics.ListAPIView): + """List chats the current user participates in.""" + + serializer_class = ChatSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + """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.""" + + 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): + """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 new file mode 100644 index 0000000..e057560 --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,111 @@ +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 +from .services import assert_can_send_message + +User = get_user_model() + + +class ChatConsumer(AsyncWebsocketConsumer): + + async def connect(self): + 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() + + # 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( + self.room_group_name, + self.channel_name + ) + + async def receive(self, text_data): + data = json.loads(text_data) + content = data.get("message", "").strip() + + if 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, + { + "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): + await self.send(text_data=json.dumps(event["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): + """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) + 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): + 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/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/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/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/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/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 ecd609e..9dd1116 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,162 +1,345 @@ """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, +chat membership, and stored messages. + +Classes: + 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 -from django.db import models +from django.contrib.auth import get_user_model +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). + + 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. -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). - 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 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() + 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') - content = models.TextField() - sent_at = models.DateTimeField(auto_now_add=True) + 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", + on_delete=models.CASCADE, + null=True, + blank=True, + 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: + verbose_name = 'Chat' + verbose_name_plural = 'Chats' + indexes = [ + models.Index(fields=["is_group", "created_at"]), + models.Index(fields=["name"]), + models.Index(fields=["is_global", "created_at"]), + ] def __str__(self): - """Return string representation of the message. - - Returns: - str: Formatted string showing sender and message 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. - + """Return the chat name if available, otherwise fallback to ID.""" + return self.name or f"Chat {self.id}" + + def get_participants(self): + """Return all users currently participating in this chat. + Returns: - bool: True if message has a receiver (private), False if lobby message. + QuerySet[User]: Distinct list of users. """ - return self.receiver is not None - - def is_lobby_message(self): - """Check if this is a lobby/group message. - + return User.objects.filter(chat_participations__chat=self).distinct() + + def has_participant(self, user): + """Determine whether a given user is part of this chat. + + Args: + user (User): The user to check. + Returns: - bool: True if message belongs to a lobby, False if private message. + bool: True if the user participates in the chat. """ - return self.lobby is not None - - def get_chat_context(self): - """Get the context (lobby or private chat) for this message. - + return ChatParticipant.objects.filter(chat=self, user=user).exists() + + def add_participant(self, user, role="member"): + """Add a user to the chat or update their role. + + Args: + user (User): User to add. + role (str): One of: "owner", "admin", "member". + Returns: - dict: Dictionary with context type and relevant object. + tuple(ChatParticipant, bool): participant instance and created flag """ - 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. - + 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 participant from the chat. + Args: - lobby (Lobby): The lobby to get messages for. - limit (int): Maximum number of messages to retrieve. - + user (User): User to remove. + Returns: - QuerySet: Recent messages in the lobby. + int: Number of deleted records (0 or 1). """ - 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. - + return ChatParticipant.objects.filter(chat=self, user=user).delete()[0] + + def get_owners(self): + """Return all owners of this chat.""" + return ChatParticipant.objects.filter(chat=self, role="owner").select_related("user") + + def get_admins(self): + """Return all admins (role admin or owner).""" + return ChatParticipant.objects.filter( + 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: - user1 (User): First user in the conversation. - user2 (User): Second user in the conversation. - limit (int): Maximum number of messages to retrieve. - + user (User): One of the two participants. + Returns: - QuerySet: Recent messages between the users. - """ - 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. + User | None: The counterpart, or None if not applicable. """ - 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. - - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. + 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. + + 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 = [ + ('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) + 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 f"{self.user.username} in {self.chat}" + + def is_owner(self): + """Return True if participant is the owner.""" + return self.role == "owner" + + def is_admin(self): + """Return True if participant has admin or owner rights.""" + return self.role in ("admin", "owner") + + def promote(self): + """Promote user to admin.""" + if self.role == "member": + self.role = "admin" + self.save(update_fields=["role"]) + + def demote(self): + """Demote admin to member.""" + if self.role == "admin": + 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. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + sender = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='sent_messages') + chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") + content = models.TextField() + sent_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + """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_lobby_message(self): + """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 self.chat.is_direct_message() + + def get_chat_context(self): + """Return structured information about the chat type. + + Returns: + dict: ``type`` is one of ``private``, ``group``, ``lobby``, ``global``; + ``name`` is a short display label. """ - self.clean() - super().save(*args, **kwargs) - + 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"} + + return {"type": "private", "name": "Private Chat"} + class Meta: verbose_name = 'Message' 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']), ] diff --git a/chat/routing.py b/chat/routing.py index 9338e54..4eca576 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 .consumers import ChatConsumer websocket_urlpatterns = [ - + # 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..1d8e1ea --- /dev/null +++ b/chat/serializers.py @@ -0,0 +1,80 @@ +"""Serializers for REST APIs in the chat application.""" + +from rest_framework import serializers + +from .models import Chat, Message + + +class MessageSerializer(serializers.ModelSerializer): + """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 = ["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", + "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/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 new file mode 100644 index 0000000..0512110 --- /dev/null +++ b/chat/tests/test_chat.py @@ -0,0 +1,138 @@ +"""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() + admin_users = [admin.user for admin in admins] + assert owners.count() == 1 + assert owners.first().user == owner + assert admins.count() == 2 + assert owner in admin_users + assert admin in admin_users + assert member not in admin_users + + +@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" + + +@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_message.py b/chat/tests/test_message.py new file mode 100644 index 0000000..79c06f7 --- /dev/null +++ b/chat/tests/test_message.py @@ -0,0 +1,260 @@ +"""Tests for the Message model in the chat app.""" + +import pytest +from django.utils import timezone + +from chat.models import Chat, Message + + +@pytest.mark.django_db +class TestMessageModel: + """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, + ) + + message = Message.objects.create( + sender=test_user, + chat=chat, + content="Hello lobby chat!", + ) + + assert message.sender == test_user + 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, + ) + + message = Message.objects.create( + sender=test_user, + chat=private_chat, + content="Hello in private chat!", + ) + + assert message.sender == test_user + 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, 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, + 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, 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, + 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, + ) + + short_content = "Short message" + message = Message.objects.create( + sender=test_user, + chat=chat, + content=short_content, + ) + + assert str(message) == f"{test_user.username}: {short_content}" + + 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, + ) + + 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, + ) + + older_message = Message.objects.create( + sender=test_user, + chat=chat, + content="Older message", + ) + newer_message = Message.objects.create( + sender=test_user, + chat=chat, + content="Newer message", + ) + + 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, + ) + 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, + ) + + 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 + + assert private_msg.is_lobby_message() is False + assert private_msg.is_private() is True + + 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 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, + ) + 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 + + private_ctx = private_msg.get_chat_context() + assert private_ctx["type"] == "private" + assert "name" in private_ctx diff --git a/chat/tests/test_models.py b/chat/tests/test_models.py deleted file mode 100644 index 2d9d2f1..0000000 --- a/chat/tests/test_models.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Tests for the Message model in the chat app.""" - -import pytest -from django.core.exceptions import ValidationError -from chat.models import Message - - -@pytest.mark.django_db -class TestMessageModel: - """Test suite for Message model.""" - - 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!" - ) - assert message.sender == test_user - assert message.receiver == second_user - assert message.lobby is None - assert message.content == "Hello, this is a private message!" - - 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!" - ) - assert message.sender == test_user - assert message.lobby == basic_lobby - assert message.receiver is None - assert message.content == "Hello everyone in the lobby!" - - def test_message_uuid_generation(self, test_user, second_user): - """Tests that UUID is automatically generated for messages.""" - message = Message.objects.create( - sender=test_user, receiver=second_user, content="Test" - ) - assert message.id is not None - 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.""" - message = Message.objects.create( - sender=test_user, receiver=second_user, content="Test" - ) - assert message.sent_at is not None - - 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 - ) - 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" - ) - lobby_message = Message.objects.create( - sender=test_user, lobby=basic_lobby, content="Public" - ) - 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" - ) - lobby_message = Message.objects.create( - sender=test_user, lobby=basic_lobby, content="Public" - ) - 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" - ) - lobby_message = Message.objects.create( - sender=test_user, lobby=basic_lobby, content="Test" - ) - - private_context = private_message.get_chat_context() - assert private_context['type'] == 'private' - assert private_context['context'] == second_user - - lobby_context = lobby_message.get_chat_context() - assert lobby_context['type'] == 'lobby' - assert lobby_context['context'] == basic_lobby - - def test_clean_validation_both_lobby_and_receiver( - self, test_user, second_user, basic_lobby - ): - """Tests clean() raises ValidationError when both lobby and receiver are set.""" - message = Message( - sender=test_user, - receiver=second_user, - lobby=basic_lobby, - content="Invalid" - ) - with pytest.raises(ValidationError, match="both lobby and receiver"): - message.clean() - - 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() 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/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 diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..ae2fdca --- /dev/null +++ b/chat/urls.py @@ -0,0 +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_inbox, chat_room, start_direct_chat + +app_name = "chat" + +urlpatterns = [ + 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 91ea44a..8322153 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,3 +1,173 @@ -from django.shortcuts import render +"""Server-rendered views for chat (same behaviour as the REST API layer).""" -# Create your views here. +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 + +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(), + }, + ) 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/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' diff --git a/requirements.txt b/requirements.txt index 454e5dc..9325eed 100644 Binary files a/requirements.txt and b/requirements.txt differ