diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a072fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: Django CI + +on: + push: + branches: ["main", "master", "develop"] + pull_request: + branches: ["main", "master", "develop"] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: django_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + + env: + DJANGO_SETTINGS_MODULE: Fools_Arena.settings + POSTGRES_DB: django_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + DATABASE_URL: postgres://postgres:postgres@localhost:5432/django_test + PYTHONPATH: . + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Wait for PostgreSQL to be ready + run: | + echo "Waiting for PostgreSQL to accept connections..." + for i in {1..10}; do + pg_isready -h localhost -p 5432 -U postgres && break + echo "PostgreSQL not ready yet, retrying in 3 seconds..." + sleep 3 + done + + - name: Run migrations + run: | + python manage.py migrate --noinput + + - name: Run tests + run: | + pytest -v --disable-warnings diff --git a/.gitignore b/.gitignore index 40dfc85..a97294d 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ celerybeat.pid *.sql.gz *.dump *.backup +backup* # ========================= # Docker diff --git a/Docs/Database/dbschema.png b/Docs/Database/dbschema.png new file mode 100644 index 0000000..994b231 Binary files /dev/null and b/Docs/Database/dbschema.png differ diff --git a/Docs/Database/dbschema.puml b/Docs/Database/dbschema.puml new file mode 100644 index 0000000..72efd8c --- /dev/null +++ b/Docs/Database/dbschema.puml @@ -0,0 +1,210 @@ +@startuml +' === LOOKUP TABLES === + +entity "CardSuit" as CardSuit { + *id : smallint + --- + name : varchar + color : ENUM(red, black) +} + +entity "CardRank" as CardRank { + *id : smallint + --- + name : varchar + value : int +} + +' === CORE ENTITIES === + +entity "User" as User { + *id : UUID + --- + username : varchar + email : varchar + password_hash : varchar + avatar_url : varchar? + created_at : timestamp +} + +entity "Lobby" as Lobby { + *id : UUID + --- + name : varchar + owner_id : UUID [FK → User.id] + is_private : bool + password_hash : varchar? + status : ENUM(waiting, playing, closed) + created_at : timestamp +} + +entity "LobbySettings" as LobbySettings { + *id : UUID + --- + lobby_id : UUID [FK → Lobby.id] + max_players : int + card_count : ENUM(24, 36, 52) + is_transferable : bool + neighbor_throw_only : bool + allow_jokers : bool + turn_time_limit : int? + special_rule_set_id : UUID [FK → SpecialRuleSet.id]? +} + +entity "LobbyPlayer" as LobbyPlayer { + *id : UUID + --- + lobby_id : UUID [FK → Lobby.id] + user_id : UUID [FK → User.id] + status : ENUM(waiting, ready, playing, left) +} + +entity "Game" as Game { + *id : UUID + --- + lobby_id : UUID [FK → Lobby.id] + trump_card_id : UUID [FK → Card.id] + started_at : timestamp + finished_at : timestamp? + status : ENUM(in_progress, finished) + loser_id : UUID [FK → User.id]? +} + +entity "GamePlayer" as GamePlayer { + *id : UUID + --- + game_id : UUID [FK → Game.id] + user_id : UUID [FK → User.id] + seat_position : int + cards_remaining : int +} + +entity "Card" as Card { + *id : UUID + --- + suit_id : smallint [FK → CardSuit.id] + rank_id : smallint [FK → CardRank.id] + special_card_id : UUID? [FK → SpecialCard.id] +} + +' === CARD STATES === + +entity "GameDeck" as GameDeck { + *id : UUID + --- + game_id : UUID [FK → Game.id] + card_id : UUID [FK → Card.id] + position : int +} + +entity "PlayerHand" as PlayerHand { + *id : UUID + --- + game_id : UUID [FK → Game.id] + player_id : UUID [FK → User.id] + card_id : UUID [FK → Card.id] + order_in_hand : int? +} + +entity "TableCard" as TableCard { + *id : UUID + --- + game_id : UUID [FK → Game.id] + attack_card_id : UUID [FK → Card.id] + defense_card_id : UUID? [FK → Card.id] +} + +entity "DiscardPile" as DiscardPile { + *id : UUID + --- + game_id : UUID [FK → Game.id] + card_id : UUID [FK → Card.id] + position : int? +} + +' === RULES === + +entity "SpecialRuleSet" as SpecialRuleSet { + *id : UUID + --- + name : varchar + description : text? + min_players : int +} + +entity "SpecialCard" as SpecialCard { + *id : UUID + --- + name : varchar + effect_type : ENUM(skip, reverse, draw, custom) + effect_value : jsonb? + description : text? +} + +entity "SpecialRuleSetCard" as SpecialRuleSetCard { + *id : UUID + --- + rule_set_id : UUID [FK → SpecialRuleSet.id] + card_id : UUID [FK → SpecialCard.id] +} + +' === GAME FLOW === + +entity "Turn" as Turn { + *id : UUID + --- + game_id : UUID [FK → Game.id] + player_id : UUID [FK → User.id] + turn_number : int +} + +entity "Move" as Move { + *id : UUID + --- + turn_id : UUID [FK → Turn.id] + table_card_id : UUID [FK → TableCard.id] + action_type : ENUM(attack, defend, pickup) + created_at : timestamp +} + +entity "Message" as Message { + *id : UUID + --- + sender_id : UUID [FK → User.id] + receiver_id : UUID? [FK → User.id] + lobby_id : UUID? [FK → Lobby.id] + content : text + sent_at : timestamp +} + +' === RELATIONSHIPS === + +User ||--o{ Lobby +User ||--o{ LobbyPlayer +User ||--o{ Message +User ||--o{ GamePlayer +Lobby ||--|| LobbySettings +Lobby ||--o{ LobbyPlayer +Lobby ||--o{ Game +Lobby ||--o{ Message +Game ||--o{ GamePlayer +Game ||--o{ Turn +Game ||--|| Card : trump > +Game ||--o{ GameDeck +Game ||--o{ PlayerHand +Game ||--o{ TableCard +Game ||--o{ DiscardPile +Game ||--|| User : loser > +Turn ||--o{ Move +TableCard ||--o{ Move +CardSuit ||--o{ Card +CardRank ||--o{ Card +Card ||--o{ GameDeck +Card ||--o{ PlayerHand +Card ||--o{ TableCard +Card ||--o{ DiscardPile +SpecialRuleSet ||--o{ LobbySettings +SpecialRuleSet ||--o{ SpecialRuleSetCard +SpecialCard ||--o{ SpecialRuleSetCard +SpecialCard ||--o{ Card +@enduml diff --git a/Docs/Database/dbschema.svg b/Docs/Database/dbschema.svg new file mode 100644 index 0000000..da6155a --- /dev/null +++ b/Docs/Database/dbschema.svg @@ -0,0 +1 @@ +CardSuitid : smallintname : varcharcolor : ENUM(red, black)CardRankid : smallintname : varcharvalue : intUserid : UUIDusername : varcharemail : varcharpassword_hash : varcharavatar_url : varchar?created_at : timestampLobbyid : UUIDname : varcharowner_id : UUID [FK → User.id]is_private : boolpassword_hash : varchar?status : ENUM(waiting, playing, closed)created_at : timestampLobbySettingsid : UUIDlobby_id : UUID [FK → Lobby.id]max_players : intcard_count : ENUM(24, 36, 52)is_transferable : boolneighbor_throw_only : boolallow_jokers : boolturn_time_limit : int?special_rule_set_id : UUID [FK → SpecialRuleSet.id]?LobbyPlayerid : UUIDlobby_id : UUID [FK → Lobby.id]user_id : UUID [FK → User.id]status : ENUM(waiting, ready, playing, left)Gameid : UUIDlobby_id : UUID [FK → Lobby.id]trump_card_id : UUID [FK → Card.id]started_at : timestampfinished_at : timestamp?status : ENUM(in_progress, finished)loser_id : UUID [FK → User.id]?GamePlayerid : UUIDgame_id : UUID [FK → Game.id]user_id : UUID [FK → User.id]seat_position : intcards_remaining : intCardid : UUIDsuit_id : smallint [FK → CardSuit.id]rank_id : smallint [FK → CardRank.id]special_card_id : UUID? [FK → SpecialCard.id]GameDeckid : UUIDgame_id : UUID [FK → Game.id]card_id : UUID [FK → Card.id]position : intPlayerHandid : UUIDgame_id : UUID [FK → Game.id]player_id : UUID [FK → User.id]card_id : UUID [FK → Card.id]order_in_hand : int?TableCardid : UUIDgame_id : UUID [FK → Game.id]attack_card_id : UUID [FK → Card.id]defense_card_id : UUID? [FK → Card.id]DiscardPileid : UUIDgame_id : UUID [FK → Game.id]card_id : UUID [FK → Card.id]position : int?SpecialRuleSetid : UUIDname : varchardescription : text?min_players : intSpecialCardid : UUIDname : varchareffect_type : ENUM(skip, reverse, draw, custom)effect_value : jsonb?description : text?SpecialRuleSetCardid : UUIDrule_set_id : UUID [FK → SpecialRuleSet.id]card_id : UUID [FK → SpecialCard.id]Turnid : UUIDgame_id : UUID [FK → Game.id]player_id : UUID [FK → User.id]turn_number : intMoveid : UUIDturn_id : UUID [FK → Turn.id]table_card_id : UUID [FK → TableCard.id]action_type : ENUM(attack, defend, pickup)created_at : timestampMessageid : UUIDsender_id : UUID [FK → User.id]receiver_id : UUID? [FK → User.id]lobby_id : UUID? [FK → Lobby.id]content : textsent_at : timestamptrumploser \ No newline at end of file diff --git a/Docs/Models.png b/Docs/Models.png deleted file mode 100644 index c193221..0000000 Binary files a/Docs/Models.png and /dev/null differ diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index d8ec850..d6bc835 100644 --- a/Fools_Arena/settings.py +++ b/Fools_Arena/settings.py @@ -48,7 +48,7 @@ # WebSockets INSTALLED_APPS += ['channels'] -ASGI_APPLICATION = 'myproject.asgi.application' +ASGI_APPLICATION = 'Fools_Arena.asgi.application' CHANNEL_LAYERS = { "default": { @@ -119,6 +119,7 @@ }, ] +AUTH_USER_MODEL = 'accounts.User' # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ @@ -136,6 +137,7 @@ # https://docs.djangoproject.com/en/5.2/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / 'staticfiles' # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index 653613e..f8e673e 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -16,8 +16,22 @@ """ from django.contrib import admin -from django.urls import path +from django.conf import settings +from django.conf.urls.static import static +from django.http import HttpResponse +from django.shortcuts import redirect +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), +# UI + path('accounts/', include('accounts.urls')), + + # API + path('api/accounts/', include('accounts.api_urls')), + ] + +# Add static files +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + diff --git a/README.md b/README.md index 25d23f3..a55fa2a 100644 --- a/README.md +++ b/README.md @@ -23,28 +23,32 @@ Available services: - Django + Channels: http://localhost:8000 - PostgreSQL: localhost:5432 -### 4. Apply migrations and create superuser +### 4. Apply migrations and create a superuser Run migrations: ```bash -docker-compose exec web python manage.py migrate +docker compose exec web python manage.py migrate ``` Create a superuser (optional): ```bash -docker-compose exec web python manage.py createsuperuser +docker compose exec web python manage.py createsuperuser ``` +### 5. Generate static files +```bash +docker compose exec web python manage.py collectstatic +``` -### 5. Work with Django +### 6. Work with Django All commands should be executed inside the web container. Examples: ```bash -docker-compose exec web python manage.py shell -docker-compose exec web python manage.py makemigrations -docker-compose exec web python manage.py test +docker compose exec web python manage.py shell +docker compose exec web python manage.py makemigrations +docker compose exec web pytest -v ``` -### 6. Stop containers +### 7. Stop containers ```bash -docker-compose down +docker compose down ``` --- @@ -59,7 +63,7 @@ docker-compose down ## 📌 Status Early development stage. -See [ROADMAP.md](./ROADMAP.md) for roadmap. +See [ROADMAP.md](./ROADMAP.md) for the roadmap. --- diff --git a/accounts/admin.py b/accounts/admin.py index 8c38f3f..8a741af 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,3 +1,198 @@ +"""Admin configuration for the Durak card game application. + +This module defines the Django admin interface configuration for all models +in the accounts app, providing a comprehensive management interface for +administrators. +""" + from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.html import format_html +from .models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """Admin interface for the custom User model. + + Extends Django's built-in UserAdmin to handle the custom fields + and provide enhanced functionality for managing users in the + Durak card game application. + + Features: + - Custom list display with avatar preview + - Enhanced filtering and search capabilities + - Readonly fields for system-generated data + - Custom fieldsets for better organization + - Avatar preview in detail view + + Attributes: + list_display: Fields shown in the user list view + list_filter: Available filters in the sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering for the user list + fieldsets: Organization of fields in the detail view + """ + + # List view configuration + list_display = ( + 'username', + 'email', + 'get_full_display_name', + 'avatar_preview', + 'is_active', + 'is_staff', + 'created_at', + 'last_login' + ) + + list_display_links = ('username', 'email') + + list_filter = ( + 'is_active', + 'is_staff', + 'is_superuser', + 'created_at', + 'last_login', + 'date_joined' + ) + + search_fields = ('username', 'email', 'first_name', 'last_name') + + readonly_fields = ('id', 'created_at', 'date_joined', 'last_login', 'avatar_display') + + ordering = ('-created_at',) + + # Detail view configuration + fieldsets = ( + ('Basic Information', { + 'fields': ('id', 'username', 'email', 'password'), + 'description': 'Core user identification and authentication fields.' + }), + ('Personal Information', { + 'fields': ('first_name', 'last_name'), + 'description': 'Optional personal details for display purposes.' + }), + ('Profile', { + 'fields': ('avatar_url', 'avatar_display'), + 'description': 'User profile customization options.' + }), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), + 'description': 'User permissions and group memberships.', + 'classes': ('collapse',) + }), + ('Important Dates', { + 'fields': ('created_at', 'date_joined', 'last_login'), + 'description': 'System-generated timestamps for user activity.', + 'classes': ('collapse',) + }), + ) + + # Add user form configuration + add_fieldsets = ( + ('User Creation', { + 'fields': ('username', 'email', 'password1', 'password2'), + 'description': 'Create a new user account for the Durak game.' + }), + ('Optional Information', { + 'fields': ('first_name', 'last_name', 'avatar_url'), + 'classes': ('collapse',) + }), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + 'classes': ('collapse',) + }), + ) + + def avatar_preview(self, obj): + """Display a small preview of the user's avatar in the list view. + + Args: + obj (User): The user instance. + + Returns: + str: HTML string with avatar image or placeholder text. + """ + if obj.has_avatar(): + return format_html( + '', + obj.avatar_url + ) + return "No avatar" + + avatar_preview.short_description = "Avatar" + + def avatar_display(self, obj): + """Display a larger preview of the user's avatar in the detail view. + + Args: + obj (User): The user instance. + + Returns: + str: HTML string with avatar image or placeholder message. + """ + if obj.has_avatar(): + return format_html( + '' + '
View full size', + obj.avatar_url, + obj.avatar_url + ) + return "No avatar uploaded" + + avatar_display.short_description = "Avatar Preview" + + def get_queryset(self, request): + """Optimize queryset for the admin list view. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related() + + def has_delete_permission(self, request, obj=None): + """Control delete permissions for user objects. + + Prevents deletion of superuser accounts by non-superusers + and adds additional safety checks. + + Args: + request: The HTTP request object. + obj (User, optional): The user object being considered for deletion. + + Returns: + bool: True if the user can delete the object, False otherwise. + """ + if obj and obj.is_superuser and not request.user.is_superuser: + return False + return super().has_delete_permission(request, obj) + + def save_model(self, request, obj, form, change): + """Custom save logic for user objects. + + Args: + request: The HTTP request object. + obj (User): The user object being saved. + form: The admin form instance. + change (bool): True if this is an update, False if creating new. + """ + # Log user creation/updates for audit purposes + if not change: + # This is a new user + obj.save() + else: + # This is an update to existing user + obj.save() + + super().save_model(request, obj, form, change) + -# Register your models here. +# Optional: Customize admin site headers +admin.site.site_header = "Durak Game Administration" +admin.site.site_title = "Durak Admin" +admin.site.index_title = "Welcome to Durak Game Administration" diff --git a/accounts/api_urls.py b/accounts/api_urls.py new file mode 100644 index 0000000..8d6dffa --- /dev/null +++ b/accounts/api_urls.py @@ -0,0 +1,26 @@ +""" +Authentication API routes for the Accounts app. + +This module defines the endpoints for user registration, login, +profile retrieval, and logout. These routes are included in the +project’s main urls.py under the prefix "api/accounts/", which means +the final URLs are: + + /api/accounts/auth/register/ → Register a new user + /api/accounts/auth/login/ → Log in an existing user + /api/accounts/auth/profile/ → Retrieve the authenticated user's profile + /api/accounts/auth/logout/ → Log out the current user + +Each path is mapped to a class-based API view defined in accounts/api_views.py. +""" + +from django.urls import path +from .api_views import RegistrationAPI, LoginAPI, ProfileAPI, LogoutAPI + + +urlpatterns = [ + path('auth/register/', RegistrationAPI.as_view(), name='api_register'), + path('auth/login/', LoginAPI.as_view(), name='api_login'), + path('auth/profile/', ProfileAPI.as_view(), name='api_profile'), + path('auth/logout/', LogoutAPI.as_view(), name='api_logout'), +] diff --git a/accounts/api_views.py b/accounts/api_views.py new file mode 100644 index 0000000..6097b31 --- /dev/null +++ b/accounts/api_views.py @@ -0,0 +1,96 @@ +""" +API views for the Accounts app. + +This module defines class-based views for handling user authentication +via RESTful endpoints. It includes registration, login, profile retrieval, +and logout functionality. These views are connected to the routes defined +in accounts/api_urls.py and use serializers from accounts/serializers.py. + +Available API views: + - RegistrationAPI: create a new user and log them in automatically. + - LoginAPI: authenticate user credentials and start a session. + - ProfileAPI: return profile data for the authenticated user. + - LogoutAPI: end the current user session. +""" + +from django.contrib.auth import login as auth_login, logout as auth_logout +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView +from .serializers import RegistrationSerializer, LoginSerializer, ProfileSerializer + +class RegistrationAPI(generics.CreateAPIView): + """ + API endpoint for user registration. + + Handles the creation of a new user account using validated input. + Automatically logs in the newly created user to establish a session. + """ + serializer_class = RegistrationSerializer + permission_classes = [permissions.AllowAny] + + def perform_create(self, serializer): + """ + Save the new user instance and log them in. + + Overrides the default CreateAPIView behavior to attach the user + to the current session immediately after registration. + """ + user = serializer.save() + auth_login(self.request, user) + +class LoginAPI(APIView): + """ + API endpoint for user login. + + Accepts username and password, authenticates the user, + and returns their profile data upon successful login. + """ + permission_classes = [permissions.AllowAny] + + def post(self, request): + """ + Authenticate user credentials and start a session. + + If credentials are valid, the user is logged in and their + profile data is returned in the response. + """ + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + auth_login(request, user) + return Response(ProfileSerializer(user).data) + +class ProfileAPI(generics.RetrieveAPIView): + """ + API endpoint for retrieving the authenticated user's profile. + + Requires the user to be logged in. Returns basic profile information. + """ + serializer_class = ProfileSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + """ + Return the current authenticated user. + + Used by RetrieveAPIView to serialize and return profile data. + """ + return self.request.user + +class LogoutAPI(APIView): + """ + API endpoint for logging out the current user. + + Requires authentication. Ends the session and returns a confirmation message. + """ + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + """ + End the current user session. + + Logs out the user and returns a success response. + """ + auth_logout(request) + return Response({'detail': 'You are out of the system'}, status=status.HTTP_200_OK) diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..945d67c --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,41 @@ +""" +Forms for the Accounts app. + +This module defines form classes used for user registration and login. +They extend Django's built-in authentication forms to include additional +fields or custom behavior where necessary. + +Available forms: + - RegistrationForm: extends UserCreationForm to include an email field. + - LoginForm: extends AuthenticationForm for user login. +""" + +from django import forms +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.contrib.auth import get_user_model + +User = get_user_model() + +class RegistrationForm(UserCreationForm): + """ + Form for user registration. + + Extends Django's built-in UserCreationForm by adding + a required email field. Handles validation and creation + of a new user instance with username, email, and password. + """ + email = forms.EmailField(required=True) + + class Meta: + model = User + fields = ('username', 'email', 'password1', 'password2') + +class LoginForm(AuthenticationForm): + """ + Form for user login. + + Extends Django's built-in AuthenticationForm without + additional fields. Used to authenticate existing users + with their username and password. + """ + pass diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..7f37fec --- /dev/null +++ b/accounts/management/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for accounts app. + +This package contains management commands for accounts application. +""" diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..7f37fec --- /dev/null +++ b/accounts/management/commands/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for accounts app. + +This package contains management commands for accounts application. +""" diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py new file mode 100644 index 0000000..d399182 --- /dev/null +++ b/accounts/management/commands/generate_test_users.py @@ -0,0 +1,322 @@ +""" +Management command: create test users for development and delete users in a marker group. + +This command has two main modes: + +1. Creation mode (default) + - Creates test users with configurable parameters: --count, --prefix, --start, + --email-domain, --password, and flags --staff / --superuser / --inactive. + - Adds created users to a marker group (default: "Test_Users") so they can be + identified and removed later. + - Supports --force to create users even when the plain username exists + (a short random suffix is appended in that case). + - Supports --dry-run to preview actions without mutating the database. + +2. Deletion mode (--delete) + - Deletes users who are members of the configured marker group (default: "Test_Users"). + - Excludes staff and superusers from deletion if the user model supports those flags. + - Shows matched users, supports --dry-run, and requires interactive confirmation + by default (use --noinput to skip confirmation). + +Examples: + # Create 3 users testuser1..testuser3 + python manage.py generate_test_users --count 3 --prefix testuser + + # Delete all users in Test_Users group without prompt + python manage.py generate_test_users --delete --noinput +""" +import argparse +from typing import List, Optional +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.db import transaction + +User = get_user_model() + + +class Command(BaseCommand): + """CLI wrapper for creating test users and deleting users in a marker group. + + Responsibilities: + - Parse command-line options and delegate to helper methods. + - Keep interactive I/O (prompts and formatted output) centralized. + + The heavy lifting is done in the private helpers `_handle_create` and + `_handle_delete` which are easier to test in isolation. + """ + + help = "Create test users or delete all users in the marker group (use --delete)." + + def add_arguments(self, parser): + """Register the command-line arguments.""" + parser.add_argument( + "--count", + "-c", + type=int, + default=1, + help="Number of users to create (default: 1).", + ) + parser.add_argument( + "--prefix", + "-p", + type=str, + default="testuser", + help='Username prefix (default: "testuser").', + ) + parser.add_argument( + "--start", + type=int, + default=1, + help="Starting index appended to username (default: 1).", + ) + parser.add_argument( + "--email-domain", + type=str, + default="example.com", + help="Email domain for generated users.", + ) + parser.add_argument( + "--password", + type=str, + default="test_password", + help="Password for created users.", + ) + parser.add_argument( + "--staff", + action="store_true", + help="Mark created users as staff.", + ) + parser.add_argument( + "--superuser", + action="store_true", + help="Create superuser(s).", + ) + parser.add_argument( + "--inactive", + action="store_true", + help="Create users with is_active=False.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Append random suffix if username exists.", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete users in marker group instead of creating.", + ) + parser.add_argument( + "--marker-group", + type=str, + default="Test_Users", + help='Group name used to mark generated users.', + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview actions without making DB changes.", + ) + parser.add_argument( + "--noinput", + action="store_true", + help="Do not prompt for confirmation when deleting.", + ) + + # ---- helpers ---- + def _make_username(self, prefix: str, idx: int) -> str: + """Return a username built from prefix and index (e.g. 'testuser3').""" + return f"{prefix}{idx}" + + def _email_for_username(self, username: str, domain: str) -> str: + """Return a simple email address for the given username and domain.""" + return f"{username}@{domain}" + + @staticmethod + def _random_suffix(length: int = 4) -> str: + """Return a short random alphanumeric suffix for collision avoidance.""" + import random, string + chars = string.ascii_lowercase + string.digits + return "".join(random.choice(chars) for _ in range(length)) + + def _handle_delete(self, marker_group_name: str, dry_run: bool, noinput: bool) -> None: + """Delete non-staff/non-superuser users who belong to the marker group. + + Behavior: + - Prints matched users and total count. + - If dry_run is True, only prints what would be deleted. + - If noinput is False, prompts interactively before deletion. + - Uses a single transaction to perform deletions atomically. + - Collects and reports failures without hiding them. + """ + try: + group = Group.objects.get(name=marker_group_name) + except Group.DoesNotExist: + self.stdout.write(self.style.WARNING( + f"Marker group '{marker_group_name}' does not exist. Nothing to delete." + )) + return + + qs = User.objects.filter(groups__name=marker_group_name) + + # Exclude privileged accounts if those attributes exist + if hasattr(User, "is_staff"): + qs = qs.exclude(is_staff=True) + if hasattr(User, "is_superuser"): + qs = qs.exclude(is_superuser=True) + + total = qs.count() + if total == 0: + self.stdout.write(self.style.WARNING("No non-staff/non-superuser users found in marker group.")) + return + + self.stdout.write(self.style.WARNING(f"Matched users for deletion (group='{marker_group_name}'): {total}")) + for u in qs: + parts = [f"username='{getattr(u, 'username', '')}'"] + if getattr(u, "email", None): + parts.append(f"email='{u.email}'") + self.stdout.write(" - " + " ".join(parts)) + + if dry_run: + self.stdout.write(self.style.WARNING("Dry run: no users were deleted.")) + return + + if not noinput: + answer = input("Delete all listed users? This is irreversible. [y/N]: ") + if answer.lower() not in ("y", "yes"): + self.stdout.write(self.style.WARNING("Aborted by user.")) + return + + deleted = 0 + failed = [] + try: + with transaction.atomic(): + for u in qs: + try: + u.delete() + deleted += 1 + except Exception as exc: + failed.append((u, exc)) + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} users from group '{marker_group_name}'.")) + + if failed: + self.stdout.write(self.style.ERROR(f"{len(failed)} deletions failed:")) + for u, exc in failed: + self.stdout.write(f" - {getattr(u, 'username', '')}: {exc}") + except Exception as exc_outer: + raise CommandError(f"Deletion transaction failed: {exc_outer}") + + def _handle_create( + self, + options, + dry_run: bool, + marker_group_name: str, + ) -> None: + """Create multiple users and add them to the marker group. + + Behavior: + - Respects `force` to append a random suffix when a plain username exists. + - Uses get_or_create semantics for the marker group (created if absent). + - Adds users to the marker group when possible; reports warnings on failure. + - Prints a success message per created user and a final summary. + """ + created: List[User] = [] + + count: int = int(options.get("count", 1)) + prefix: str = options.get("prefix") or "testuser" + start: int = int(options.get("start", 1)) + email_domain: str = options.get("email_domain") or "example.com" + password: str = options.get("password") or "test_password" + make_staff: bool = bool(options.get("staff")) + make_superuser: bool = bool(options.get("superuser")) + inactive: bool = bool(options.get("inactive")) + force: bool = bool(options.get("force")) + + group_obj: Optional[Group] = None + try: + group_obj, _ = Group.objects.get_or_create(name=marker_group_name) + except Exception: + group_obj = None + + for i in range(start, start + count): + username = self._make_username(prefix, i) + email = self._email_for_username(username, email_domain) + + if User.objects.filter(username=username).exists(): + if not force: + self.stdout.write(self.style.WARNING(f"Skipping existing username: {username}")) + continue + username = f"{username}_{self._random_suffix()}" + email = self._email_for_username(username, email_domain) + try: + self.stdout.write(self.style.NOTICE(f"Username existed; using fallback username: {username}")) + except Exception: + # Some Django versions may not provide NOTICE style + self.stdout.write(f"NOTICE: Username existed; using fallback username: {username}") + + if dry_run: + self.stdout.write( + f"[DRY RUN] Would create username='{username}', email='{email}', staff={make_staff}, superuser={make_superuser}, active={not inactive}" + ) + continue + + if make_superuser: + user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] + try: + user.is_staff = True + user.is_superuser = True + except Exception: + pass + else: + user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] + try: + user.is_staff = bool(make_staff) + user.is_superuser = False + except Exception: + pass + + try: + user.is_active = not bool(inactive) + except Exception: + pass + + try: + if group_obj is not None and hasattr(user, "groups"): + user.groups.add(group_obj) + except Exception: + self.stdout.write(self.style.WARNING(f"Warning: couldn't add user '{username}' to group '{marker_group_name}'")) + + user.save() + created.append(user) + self.stdout.write(self.style.SUCCESS(f"Created user: username='{username}' email='{email}'")) + + # final summary (SQL_TABLE may not exist in all versions, fall back if needed) + try: + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) + except Exception: + self.stdout.write(f"Total users created: {len(created)}") + + # ---- entry point ---- + def handle(self, *args, **options): + """Parse CLI options and dispatch to the create or delete handler. + + This method is intentionally short: it validates and extracts options + and then delegates functionality to `_handle_delete` or `_handle_create`. + """ + dry_run: bool = options.get("dry_run", False) + marker_group_name: str = options.get("marker_group") or "Test_Users" + + # Deletion mode + if options.get("delete"): + self._handle_delete(marker_group_name=marker_group_name, dry_run=dry_run, noinput=options.get("noinput", False)) + return + + # Creation mode: collect options and delegate + + + self._handle_create( + options=options, + dry_run=dry_run, + marker_group_name=marker_group_name, + ) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..3a305e4 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.6 on 2025-10-06 19:16 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('avatar_url', models.URLField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/accounts/migrations/0002_alter_user_options.py b/accounts/migrations/0002_alter_user_options.py new file mode 100644 index 0000000..115275e --- /dev/null +++ b/accounts/migrations/0002_alter_user_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ['username'], 'verbose_name': 'User', 'verbose_name_plural': 'Users'}, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 71a8362..00838a5 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,187 @@ +"""Accounts models for the Durak card game application. + +This module contains all the Django models used in the account system for +the online multiplayer Durak card game. +""" + +import uuid +from django.contrib.auth.models import AbstractUser from django.db import models -# Create your models here. + +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. + + 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) + + Example: + # Create a new user + user = User.objects.create_user( + username='player1', + email='player1@example.com', + password='secure_password' + ) + 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. + + 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. + + Returns: + str: 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. + + Returns: + bool: True if avatar_url is set, False otherwise. + """ + return bool(self.avatar_url) + + def get_active_lobby(self): + """Get the lobby this user is currently participating in. + + Returns: + Lobby: The lobby where user has active status, or None. + """ + from game.models import LobbyPlayer + try: + lobby_player = LobbyPlayer.objects.get( + user=self, + status__in=['waiting', 'ready', 'playing'] + ) + return lobby_player.lobby + except LobbyPlayer.DoesNotExist: + return None + + def get_current_game(self): + """Get the game this user is currently playing. + + Returns: + Game: The active game the user is participating in, or None. + """ + from game.models import GamePlayer + try: + game_player = GamePlayer.objects.select_related('game').get( + user=self, + game__status='in_progress' + ) + return game_player.game + except GamePlayer.DoesNotExist: + return None + + def can_join_lobby(self, lobby): + """Check if this user can join a specific lobby. + + Args: + lobby (Lobby): The lobby to check joining permissions for. + + Returns: + bool: True if user can join, False otherwise. + """ + # 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. + + Returns: + bool: True if user was in a lobby and left, False if not in a lobby. + """ + from game.models import LobbyPlayer + try: + lobby_player = LobbyPlayer.objects.get( + user=self, + status__in=['waiting', 'ready', 'playing'] + ) + lobby_player.leave_lobby() + return True + except LobbyPlayer.DoesNotExist: + return False + + def get_game_statistics(self): + """Get basic game statistics for this user. + + Returns: + dict: Dictionary containing games played, won, and win rate. + """ + 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 { + 'total_games': 0, + 'games_won': 0, + '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 + + 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'] diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..979d751 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,76 @@ +""" +Serializers for the Accounts app. + +This module defines serializers used for user authentication and profile +management. They handle validation and transformation of input/output data +between Django models and API views. + +Available serializers: + - RegistrationSerializer: validates and creates new user accounts. + - LoginSerializer: authenticates existing users with username/password. + - ProfileSerializer: returns basic profile information for authenticated users. +""" + +from django.contrib.auth import authenticate, get_user_model +from rest_framework import serializers + +User = get_user_model() + +class RegistrationSerializer(serializers.ModelSerializer): + """ + Serializer for user registration. + + Validates the provided username, email, and password. + Creates a new user instance with an encrypted password. + """ + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ('username', 'email', 'password') + + def create(self, validated_data): + """ + Create a new user with the given validated data. + + Uses Django's built-in create_user method to ensure + the password is properly hashed before saving. + """ + return User.objects.create_user( + username=validated_data['username'], + email=validated_data.get('email', ''), + password=validated_data['password'], + ) + +class LoginSerializer(serializers.Serializer): + """ + Serializer for user login. + + Accepts username and password, and authenticates the user + using Django's built-in authentication system. + """ + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + """ + Validate the provided credentials. + + If authentication fails, raise a ValidationError. + On success, attach the authenticated user to attrs. + """ + user = authenticate(username=attrs['username'], password=attrs['password']) + if not user: + raise serializers.ValidationError('Incorrect login details') + attrs['user'] = user + return attrs + +class ProfileSerializer(serializers.ModelSerializer): + """ + Serializer for displaying user profile data. + + Returns basic information about the authenticated user. + """ + class Meta: + model = User + fields = ('id', 'username', 'email') diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html new file mode 100644 index 0000000..ca0bc63 --- /dev/null +++ b/accounts/templates/accounts/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +

Login

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

+ Don't have an account? Registration +

+{% endblock %} diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html new file mode 100644 index 0000000..68191ef --- /dev/null +++ b/accounts/templates/accounts/profile.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Profile{% endblock %} + +{% block content %} +

Profile

+

User name: {{ user.username }}

+

Email: {{ user.email }}

+
+ {% csrf_token %} + +
+{% endblock %} diff --git a/accounts/templates/accounts/registration.html b/accounts/templates/accounts/registration.html new file mode 100644 index 0000000..52bbee1 --- /dev/null +++ b/accounts/templates/accounts/registration.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Register{% endblock %} + +{% block content %} +

Registration

+
+ {% csrf_token %} + {{ form.as_p }} + +
+Already exist? Login +{% endblock %} diff --git a/accounts/templates/base.html b/accounts/templates/base.html new file mode 100644 index 0000000..61e5dd0 --- /dev/null +++ b/accounts/templates/base.html @@ -0,0 +1,27 @@ + + + + + {% block title %}Fools Arena{% endblock %} + + +
+

Fools Arena

+ +
+ +
+ {% block content %} + + {% endblock %} +
+ +
+

© 2025 Fools Arena

+
+ + diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..a939001 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,4 @@ +"""Test suite for accounts app. + +This package contains comprehensive tests for all accounts-related models, +""" diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py new file mode 100644 index 0000000..c08c576 --- /dev/null +++ b/accounts/tests/test_auth.py @@ -0,0 +1,199 @@ +"""Authentication tests for both UI and REST API endpoints. + +This module contains test cases for verifying authentication flows in +a Django application that provides both template-based (UI) and +REST API endpoints. Tests cover registration, login, logout, and +profile access behaviors. +""" + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse + +User = get_user_model() + + +@pytest.mark.django_db +class TestTemplateAuth: + """Test suite for UI-based authentication using Django templates.""" + + def test_register_valid(self, client): + """Test successful registration through UI. + + Sends valid registration data through a POST request to the 'register' view. + Ensures that the user is created and redirected to the profile page. + + Args: + client (django.test.Client): Django test client fixture. + """ + resp = client.post(reverse('register'), { + 'username': 'maksim', + 'email': 'm@example.com', + 'password1': 'StrongPass123', + 'password2': 'StrongPass123', + }) + assert resp.status_code == 302 + assert resp.url == reverse('profile') + assert User.objects.filter(username='maksim').exists() + + def test_register_invalid_password_mismatch(self, client): + """Test registration with mismatched passwords. + + Ensures that invalid password confirmation prevents user creation + and that the registration form is re-rendered with status 200. + """ + resp = client.post(reverse('register'), { + 'username': 'bad', + 'email': 'b@example.com', + 'password1': 'StrongPass123', + 'password2': 'StrongPass124', + }) + assert resp.status_code == 200 + assert not User.objects.filter(username='bad').exists() + + def test_login_valid(self, client, user_factory): + """Test successful login through UI. + + Verifies that valid credentials redirect the user to the profile page. + """ + user = user_factory(password="test123") + resp = client.post(reverse('login'), { + 'username': user.username, + 'password': 'test123', + }) + assert resp.status_code == 302 + assert resp.url == reverse('profile') + + def test_login_invalid(self, client): + """Test login with invalid credentials. + + Ensures the response remains on the login page (status 200) and does not redirect. + """ + resp = client.post(reverse('login'), { + 'username': 'nope', + 'password': 'wrong' + }) + assert resp.status_code == 200 + + def test_profile_requires_authentication(self, client): + """Test profile page access without authentication. + + Ensures that unauthenticated users are redirected to the login page. + """ + resp = client.get(reverse('profile')) + assert resp.status_code == 302 + assert reverse('login') in resp.url + + def test_logout(self, client, user_factory): + """Test logout functionality through UI. + + Verifies that an authenticated user is logged out and redirected to the login page. + """ + user = user_factory(password="test123") + client.post(reverse('login'), {'username': user.username, 'password': 'test123'}) + resp = client.post(reverse('logout')) + assert resp.status_code == 302 + assert resp.url == reverse('login') + + +@pytest.mark.django_db +class TestAPIAuth: + """Test suite for REST API authentication endpoints.""" + + def test_api_register_valid(self, api_client): + """Test successful API registration. + + Sends valid user data to the registration endpoint and verifies + that a new user is created with HTTP 201 Created. + """ + register_url = reverse("api_register") + resp = api_client.post(register_url, { + 'username': 'maksim_api', + 'email': 'mapi@example.com', + 'password': 'StrongPass123', + }, format='json') + assert resp.status_code == 201 + assert User.objects.filter(username='maksim_api').exists() + + def test_api_register_invalid(self, api_client): + """Test API registration with invalid data. + + Ensures that malformed input returns HTTP 400 Bad Request. + """ + register_url = reverse("api_register") + resp = api_client.post(register_url, { + 'username': '', + 'email': 'bad', + 'password': 'short', + }, format='json') + assert resp.status_code == 400 + + def test_api_login_valid(self, api_client, user_factory): + """Test successful API login. + + Sends valid credentials to the login endpoint and verifies that + profile data is returned in the response. + """ + user = user_factory(password="test123") + url = reverse("api_login") + resp = api_client.post(url, { + 'username': user.username, + 'password': 'test123' + }, format='json') + assert resp.status_code == 200 + assert resp.data['username'] == user.username + + def test_api_login_invalid(self, api_client): + """Test API login with invalid credentials. + + Ensures that incorrect credentials return HTTP 400 Bad Request. + """ + url = reverse("api_login") + resp = api_client.post(url, { + 'username': 'nope', + 'password': 'wrong' + }, format='json') + assert resp.status_code == 400 + + def test_api_profile_authenticated(self, api_client, user_factory): + """Test authenticated API profile access. + + After logging in, verifies that the authenticated user can retrieve + their own profile data. + """ + login_url = reverse("api_login") + user = user_factory(password="test123") + api_client.post(login_url, { + 'username': user.username, + 'password': 'test123' + }, format='json') + profile_url = reverse("api_profile") + resp = api_client.get(profile_url) + assert resp.status_code == 200 + assert resp.data['username'] == user.username + + def test_api_profile_unauthenticated(self, api_client): + """Test unauthenticated API profile access. + + Ensures that accessing the profile endpoint without authentication + returns HTTP 403 Forbidden. + """ + profile_url = reverse("api_profile") + resp = api_client.get(profile_url) + assert resp.status_code == 403 + + def test_api_logout(self, api_client, user_factory): + """Test API logout. + + Verifies that an authenticated user can log out successfully, + receiving HTTP 200 OK in response. + """ + login_url = reverse("api_login") + user = user_factory(password="test123") + api_client.post(login_url, { + 'username': user.username, + 'password': 'test123' + }, format='json') + logout_url = reverse("api_logout") + resp = api_client.post(logout_url) + assert resp.status_code == 200 diff --git a/accounts/tests/test_generate_test_users.py b/accounts/tests/test_generate_test_users.py new file mode 100644 index 0000000..5e2a0f2 --- /dev/null +++ b/accounts/tests/test_generate_test_users.py @@ -0,0 +1,106 @@ +""" +Tests for accounts.generate_test_users management command. + +This module exercises the `generate_test_users` management command by: +- creating a set of test users with a given prefix and marker group, +- asserting the expected count changes, +- verifying that deletion via the same command removes the created users, +- and checking conflict/force behavior and group membership. +""" + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.management import call_command +import pytest + +User = get_user_model() + + +@pytest.mark.django_db +def test_generate_test_users_creates_and_adds_group(user_factory): + """Create a small batch of test users and verify they are added to a group. + + The test records the baseline set of users with the given prefix, runs the + command to create `test_users_count` users, asserts the user count grew by + that amount, and then deletes those users using the command's `--delete` + flag and verifies cleanup. + """ + + # Configuration for this test (easy to change in one place) + test_users_count = 3 + prefix = "test_" + marker_group = "Test_Users" + + # Baseline + before = list(User.objects.filter(username__startswith=prefix).order_by("id")) + + # Create test users + call_command( + "generate_test_users", + "--count", + str(test_users_count), + "--prefix", + prefix, + "--start", + "1", + "--marker-group", + marker_group, + ) + + after = list(User.objects.filter(username__startswith=prefix).order_by("id")) + assert len(after) >= len(before) + test_users_count + + # Ensure marker group exists and some of the created users are in it + group = Group.objects.filter(name=marker_group).first() + assert group is not None, "Expected the marker group to be created" + assert group.user_set.filter(username__startswith=prefix).exists() + + # Clean up via the same command (non-interactive) + call_command("generate_test_users", "--delete", "--marker-group", marker_group, "--noinput") + + remaining = list(User.objects.filter(username__startswith=prefix).order_by("id")) + # After deletion, remaining should be <= before + assert len(remaining) <= len(before) + + +@pytest.mark.django_db +def test_generate_test_users_force_and_conflict(user_factory): + """Test behavior when existing usernames conflict with the generated prefix. + + The test creates an existing user whose username would conflict with the + generated users' prefix, then runs the command with `--force` and asserts + that at least one user with that prefix exists and that the marker group + contains at least one such user (i.e. the command created or assigned a + user to the group). + """ + + prefix = "conflictuser" + marker_group = "Test_Users" + + # Create a user that would share the prefix + u = user_factory(username=prefix) + + before_count = User.objects.filter(username__startswith=prefix).count() + + # Create with same prefix and start so a conflict may occur, pass --force + call_command( + "generate_test_users", + "--count", + "1", + "--prefix", + prefix, + "--start", + "1", + "--force", + "--marker-group", + marker_group, + ) + + after_count = User.objects.filter(username__startswith=prefix).count() + # Ensure count did not decrease and (ideally) increased by at least 1 + assert after_count > before_count + + # Ensure marker group exists and contains at least one user with the prefix + group = Group.objects.filter(name=marker_group).first() + assert group is not None, "Expected the marker group to be created" + assert group.user_set.filter(username__startswith=prefix).exists() diff --git a/accounts/tests/test_models.py b/accounts/tests/test_models.py new file mode 100644 index 0000000..ccea592 --- /dev/null +++ b/accounts/tests/test_models.py @@ -0,0 +1,83 @@ +"""Tests for the User model in the accounts app.""" + +import pytest +from django.contrib.auth import get_user_model + +User = get_user_model() + + +@pytest.mark.django_db +class TestUserModel: + """Test suite for the User model.""" + + def test_user_creation(self, test_user): + """Tests that User instances are created correctly.""" + assert test_user.username == "player1" + assert test_user.email == "player1@example.com" + assert test_user.check_password("test123") + + def test_user_uuid_generation(self, test_user): + """Tests that UUID is automatically generated for users.""" + assert test_user.id is not None + assert len(str(test_user.id)) == 36 + + def test_user_str_representation(self, test_user): + """Tests string representation of User.""" + assert str(test_user) == "player1" + + def test_user_created_at_auto_generation(self, test_user): + """Tests that created_at timestamp is automatically set.""" + assert test_user.created_at is not None + + def test_get_full_display_name_with_full_name(self, test_user): + """Tests get_full_display_name() returns full name when available.""" + test_user.first_name = "John" + test_user.last_name = "Doe" + test_user.save() + assert test_user.get_full_display_name() == "John Doe" + + def test_get_full_display_name_without_full_name(self, test_user): + """Tests get_full_display_name() falls back to username.""" + assert test_user.get_full_display_name() == "player1" + + def test_has_avatar_true(self, user_factory): + """Tests has_avatar() returns True when avatar is set.""" + user_with_avatar = user_factory( + username="avataruser", + avatar_url="https://example.com/avatar.jpg" + ) + assert user_with_avatar.has_avatar() is True + + def test_has_avatar_false(self, test_user): + """Tests has_avatar() returns False when avatar is not set.""" + assert test_user.has_avatar() is False + + def test_user_ordering(self, user_factory): + """Tests that users are ordered by username.""" + user_factory(username="zzz") + user_factory(username="aaa") + users = list(User.objects.all()) + assert users[0].username == "aaa" + assert users[-1].username == "zzz" + + def test_create_superuser(self): + """Tests creating a superuser.""" + admin = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="admin123" + ) + assert admin.is_staff is True + assert admin.is_superuser is True + + def test_password_hashing(self, test_user): + """Tests that passwords are properly hashed.""" + assert test_user.password != "test123" + assert test_user.check_password("test123") + assert not test_user.check_password("wrongpassword") + + def test_authentication_fields(self, test_user): + """Tests that inherited authentication fields work correctly.""" + assert test_user.is_active is True + assert test_user.is_staff is False + assert test_user.is_superuser is False diff --git a/accounts/tests/test_user_game.py b/accounts/tests/test_user_game.py new file mode 100644 index 0000000..7284390 --- /dev/null +++ b/accounts/tests/test_user_game.py @@ -0,0 +1,52 @@ +"""Tests for game-related methods on the User model.""" + +import pytest +from game.models import GamePlayer + + +@pytest.mark.django_db +class TestUserGameMethods: + """Test suite for user methods related to game interactions.""" + + def test_get_current_game_no_game(self, test_user): + """ + Tests get_current_game() returns None when user is not in a game. + + Args: + test_user: A fixture for a test user. + """ + assert test_user.get_current_game() is None + + def test_get_current_game_active_game(self, basic_game, test_user): + """ + Tests get_current_game() returns game when user is playing. + + Args: + basic_game: A fixture for a basic game instance. + test_user: A fixture for a test user. + """ + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + assert test_user.get_current_game() == basic_game + + def test_get_current_game_finished_game(self, basic_game, test_user): + """ + Tests get_current_game() returns None for finished games. + + Args: + basic_game: A fixture for a basic game instance. + test_user: A fixture for a test user. + """ + basic_game.status = 'finished' + basic_game.save() + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=0 + ) + assert test_user.get_current_game() is None diff --git a/accounts/tests/test_user_lobby.py b/accounts/tests/test_user_lobby.py new file mode 100644 index 0000000..a2551dc --- /dev/null +++ b/accounts/tests/test_user_lobby.py @@ -0,0 +1,116 @@ +"""Tests for lobby-related methods on the User model.""" + +import pytest +from game.models import LobbyPlayer + + +@pytest.mark.django_db +class TestUserLobbyMethods: + """Test suite for user methods related to lobby interactions.""" + + def test_get_active_lobby_no_lobby(self, test_user): + """ + Tests get_active_lobby() returns None when user is not in a lobby. + + Args: + test_user: A fixture for a test user. + """ + assert test_user.get_active_lobby() is None + + @pytest.mark.parametrize("status", ["waiting", "ready", "playing"]) + def test_get_active_lobby_active_statuses(self, basic_lobby, test_user, status): + """ + Tests get_active_lobby() returns the lobby for active player statuses. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + status: The status to test. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status=status) + assert test_user.get_active_lobby() == basic_lobby + + def test_get_active_lobby_left_status(self, basic_lobby, test_user): + """ + Tests get_active_lobby() returns None when user has left the lobby. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='left') + assert test_user.get_active_lobby() is None + + def test_can_join_lobby_success(self, lobby_factory, test_user, second_user): + """ + Tests can_join_lobby() returns True for a valid join scenario. + + Args: + lobby_factory: A fixture to create lobbies. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + lobby = lobby_factory(owner=second_user, name="Open Lobby") + assert test_user.can_join_lobby(lobby) is True + + def test_can_join_lobby_already_in_lobby(self, basic_lobby, test_user, lobby_factory, second_user): + """ + Tests can_join_lobby() returns False when user is already in a lobby. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + lobby_factory: A fixture to create lobbies. + second_user: A fixture for a second test user. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='waiting') + other_lobby = lobby_factory(owner=second_user) + assert test_user.can_join_lobby(other_lobby) is False + + def test_can_join_lobby_full(self, lobby_factory, user_factory, test_user): + """ + Tests can_join_lobby() returns False when the lobby is full. + + Args: + lobby_factory: A fixture to create lobbies. + user_factory: A fixture to create users. + test_user: A fixture for a test user. + """ + owner = user_factory(username='owner') + full_lobby = lobby_factory(owner=owner, max_players=1) + LobbyPlayer.objects.create(lobby=full_lobby, user=owner, status='waiting') + assert test_user.can_join_lobby(full_lobby) is False + + def test_can_join_lobby_closed(self, lobby_factory, test_user, second_user): + """ + Tests can_join_lobby() returns False for closed lobbies. + + Args: + lobby_factory: A fixture to create lobbies. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + closed_lobby = lobby_factory(owner=second_user, status='closed') + assert test_user.can_join_lobby(closed_lobby) is False + + def test_leave_current_lobby_success(self, basic_lobby, test_user): + """ + Tests leave_current_lobby() successfully removes user from lobby. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='waiting') + assert test_user.leave_current_lobby() is True + assert test_user.get_active_lobby() is None + + def test_leave_current_lobby_not_in_lobby(self, test_user): + """ + Tests leave_current_lobby() returns False when not in a lobby. + + Args: + test_user: A fixture for a test user. + """ + + assert test_user.leave_current_lobby() is False diff --git a/accounts/tests/test_user_statistics.py b/accounts/tests/test_user_statistics.py new file mode 100644 index 0000000..4e8dd38 --- /dev/null +++ b/accounts/tests/test_user_statistics.py @@ -0,0 +1,91 @@ +"""Tests for statistics-related methods on the User model.""" + +import pytest +from game.models import GamePlayer + + +@pytest.mark.django_db +class TestUserStatisticsMethods: + """Test suite for user methods related to game statistics.""" + + def test_get_game_statistics_no_games(self, test_user): + """ + Tests get_game_statistics() with no games played. + + Args: + test_user: A fixture for a test user. + """ + stats = test_user.get_game_statistics() + assert stats['total_games'] == 0 + assert stats['games_won'] == 0 + assert stats['games_lost'] == 0 + assert stats['win_rate'] == 0.0 + + def test_get_game_statistics_with_wins_and_losses( + self, game_factory, basic_lobby, basic_cards, test_user, second_user + ): + """ + Tests get_game_statistics() with mixed results. + + Args: + game_factory: A fixture to create games. + basic_lobby: A fixture for a basic lobby instance. + basic_cards: A fixture for basic card instances. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + # Create 3 finished games: 2 wins, 1 loss for test_user + for i in range(3): + game = game_factory( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='finished', + loser=test_user if i == 0 else second_user + ) + GamePlayer.objects.create(game=game, user=test_user, seat_position=1, cards_remaining=6) + GamePlayer.objects.create(game=game, user=second_user, seat_position=2, cards_remaining=6) + + stats = test_user.get_game_statistics() + assert stats['total_games'] == 3 + assert stats['games_won'] == 2 + assert stats['games_lost'] == 1 + assert stats['win_rate'] == 66.7 + + def test_get_game_statistics_ignores_active_games( + self, game_factory, basic_lobby, basic_cards, test_user, second_user + ): + """ + Tests get_game_statistics() only counts finished games. + + Args: + game_factory: A fixture to create games. + basic_lobby: A fixture for a basic lobby instance. + basic_cards: A fixture for basic card instances. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + # Create an active game (should be ignored) + active_game = game_factory( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='in_progress' + ) + GamePlayer.objects.create(game=active_game, user=test_user, seat_position=1, cards_remaining=6) + + # Create a finished game (should be counted) + finished_game = game_factory( + lobby=basic_lobby, + trump_card=basic_cards['king_spades'], + status='finished', + loser=second_user # test_user wins + ) + GamePlayer.objects.create(game=finished_game, user=test_user, seat_position=1, cards_remaining=6) + GamePlayer.objects.create( game=finished_game, user=second_user, seat_position=2, cards_remaining=6) + + stats = test_user.get_game_statistics() + + # Should only count the finished game + assert stats['total_games'] == 1 + assert stats['games_won'] == 1 + assert stats['games_lost'] == 0 + assert stats['win_rate'] == 100.0 diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..0444d0f --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,25 @@ +""" +Authentication template routes for the Accounts app. + +This module defines the endpoints for user registration, login, +profile display, and logout. These routes are included in the +project’s main urls.py under the prefix "accounts/", which means +the final URLs are: + + /accounts/register/ → Render the registration form and create a new user + /accounts/login/ → Render the login form and authenticate a user + /accounts/profile/ → Display the authenticated user's profile page + /accounts/logout/ → Log out the current user and redirect accordingly + +Each path is mapped to a function-based view defined in accounts/views.py. +""" + +from django.urls import path +from .views import register_view, login_view, profile_view, logout_view + +urlpatterns = [ + path('register/', register_view, name='register'), + path('login/', login_view, name='login'), + path('profile/', profile_view, name='profile'), + path('logout/', logout_view, name='logout'), +] diff --git a/accounts/views.py b/accounts/views.py index 91ea44a..55ce079 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,84 @@ -from django.shortcuts import render +""" +Views for the Accounts app. -# Create your views here. +This module defines function-based views for handling user authentication +through HTML templates. It includes registration, login, profile display, +and logout functionality. These views are connected to the routes defined +in accounts/urls.py and render templates located in accounts/templates/accounts/. + +Available views: + - register_view: render and process the registration form. + - login_view: render and process the login form. + - profile_view: display the authenticated user's profile page. + - logout_view: log out the current user and redirect accordingly. +""" + +from django.contrib.auth import login as auth_login, logout as auth_logout +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from django.views.decorators.csrf import csrf_protect + +from .forms import RegistrationForm, LoginForm + +@csrf_protect +def register_view(request): + """ + Render and process the registration form. + + If the request method is POST and the form is valid, a new user + is created and automatically logged in. On success, the user is + redirected to the profile page. Otherwise, the registration form + is re-rendered with validation errors. + """ + if request.method == 'POST': + form = RegistrationForm(request.POST) + if form.is_valid(): + user = form.save() + auth_login(request, user) + return redirect('profile') + else: + form = RegistrationForm() + return render(request, 'accounts/registration.html', {'form': form}) + +@csrf_protect +def login_view(request): + """ + Render and process the login form. + + If the request method is POST and the form is valid, the user + is authenticated and logged in. On success, the user is redirected + to the profile page. Otherwise, the login form is re-rendered with + validation errors. + """ + if request.method == 'POST': + form = LoginForm(request, data=request.POST) + if form.is_valid(): + auth_login(request, form.get_user()) + return redirect('profile') + else: + form = LoginForm() + return render(request, 'accounts/login.html', {'form': form}) + +@login_required +def profile_view(request): + """ + Display the authenticated user's profile page. + + Requires the user to be logged in. If the user is not authenticated, + they will be redirected to the login page. + """ + return render(request, 'accounts/profile.html') + +@csrf_protect +def logout_view(request): + """ + Log out the current user. + + If the request method is POST, the user is logged out and redirected + to the login page. For non-POST requests, the user is redirected + back to the profile page. + """ + if request.method == 'POST': + auth_logout(request) + return redirect('login') + return redirect('profile') diff --git a/chat/admin.py b/chat/admin.py index 8c38f3f..18b3407 100644 --- a/chat/admin.py +++ b/chat/admin.py @@ -1,3 +1,410 @@ +"""Admin configuration for the chat system of the Durak card game application. + +This module defines the Django admin interface configuration for all models +in the chat app, providing comprehensive management tools for administrators +to monitor and manage chat functionality. +""" + from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta +from .models import Message + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + """Admin interface for the Message model. + + Provides comprehensive management capabilities for chat messages including + both lobby-based group messages and private direct messages between users. + Features advanced filtering, search, and moderation tools for administrators. + + Features: + - Differentiated display for lobby vs private messages + - Content preview with truncation for long messages + - Advanced filtering by message type, date, and participants + - Bulk actions for message moderation + - Enhanced search across users and content + - Readonly fields for system-generated data + - Custom validation and safety checks + + Attributes: + list_display: Fields shown in the message list view + list_display_links: Clickable fields in the list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + date_hierarchy: Date-based navigation + ordering: Default ordering for the message list + fieldsets: Organization of fields in the detail view + actions: Custom bulk actions available + """ + + # List view configuration + list_display = ( + 'message_preview', + 'sender', + 'message_type_display', + 'chat_context_display', + 'sent_at_formatted', + 'character_count', + 'is_recent' + ) + + list_display_links = ('message_preview',) + + list_filter = ( + 'sent_at', + ('sender', admin.RelatedOnlyFieldListFilter), + ('receiver', admin.RelatedOnlyFieldListFilter), + ('lobby', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'content', + 'sender__username', + 'sender__email', + 'receiver__username', + 'lobby__name', + ) + + readonly_fields = ( + 'id', + 'sent_at', + 'message_type_display', + 'chat_context_display', + 'character_count', + 'word_count', + 'content_preview_formatted' + ) + + date_hierarchy = 'sent_at' + + ordering = ('-sent_at',) + + # Detail view configuration + fieldsets = ( + ('Message Information', { + '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.' + }), + ('Message Analysis', { + 'fields': ('message_type_display', 'chat_context_display', 'character_count', 'word_count'), + 'description': 'Automated analysis of message properties.', + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('sent_at',), + 'description': 'System-generated timing information.', + 'classes': ('collapse',) + }), + ) + + # Custom actions + actions = ['mark_as_reviewed', 'export_conversation', 'delete_selected_messages'] + + def message_preview(self, obj): + """Display a truncated preview of the message content. + + Args: + obj (Message): The message instance. + + Returns: + str: Truncated message content with sender information. + """ + preview = obj.content[:60] + "..." if len(obj.content) > 60 else obj.content + return f"{obj.sender.username}: {preview}" + + message_preview.short_description = "Message Preview" + message_preview.admin_order_field = 'content' + + def message_type_display(self, obj): + """Display the type of message (Private or Lobby) with visual indicator. + + Args: + obj (Message): The message instance. + + Returns: + str: HTML formatted message type with color coding. + """ + if obj.is_private(): + return format_html( + '🔒 Private' + ) + elif obj.is_lobby_message(): + return format_html( + '💬 Lobby' + ) + return format_html( + '❓ Unknown' + ) + + message_type_display.short_description = "Message Type" + + def chat_context_display(self, obj): + """Display the chat context with appropriate formatting and links. + + Args: + obj (Message): The message instance. + + Returns: + 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]) + return format_html( + '📋 {}', + lobby_url, + obj.lobby.name + ) + elif context['type'] == 'private' and obj.receiver: + receiver_url = reverse('admin:accounts_user_change', args=[obj.receiver.pk]) + return format_html( + '👤 Private with {}', + receiver_url, + obj.receiver.username + ) + + return format_html('❓ Unknown Context') + + chat_context_display.short_description = "Chat Context" + + def sent_at_formatted(self, obj): + """Display formatted timestamp with relative time information. + + Args: + obj (Message): The message instance. + + Returns: + str: Formatted datetime with relative time indicator. + """ + now = timezone.now() + time_diff = now - obj.sent_at + + if time_diff < timedelta(minutes=1): + relative = "just now" + elif time_diff < timedelta(hours=1): + minutes = int(time_diff.total_seconds() / 60) + relative = f"{minutes}m ago" + elif time_diff < timedelta(days=1): + hours = int(time_diff.total_seconds() / 3600) + relative = f"{hours}h ago" + else: + days = time_diff.days + relative = f"{days}d ago" + + return format_html( + '{}
({})', + obj.sent_at.strftime('%Y-%m-%d %H:%M'), + relative + ) + + sent_at_formatted.short_description = "Sent At" + sent_at_formatted.admin_order_field = 'sent_at' + + def is_recent(self, obj): + """Display whether the message was sent recently. + + Args: + obj (Message): The message instance. + + Returns: + str: Visual indicator for recent messages. + """ + now = timezone.now() + time_diff = now - obj.sent_at + + if time_diff < timedelta(minutes=5): + return format_html('🟢 Very Recent') + elif time_diff < timedelta(hours=1): + return format_html('🟡 Recent') + else: + return format_html('⚪ Old') + + is_recent.short_description = "Recency" + is_recent.admin_order_field = 'sent_at' + + def character_count(self, obj): + """Display the character count of the message content. + + Args: + obj (Message): The message instance. + + Returns: + int: Number of characters in the message content. + """ + return len(obj.content) + + character_count.short_description = "Characters" + character_count.admin_order_field = 'content' + + def word_count(self, obj): + """Display the word count of the message content. + + Args: + obj (Message): The message instance. + + Returns: + int: Number of words in the message content. + """ + return len(obj.content.split()) + + word_count.short_description = "Words" + + def content_preview_formatted(self, obj): + """Display formatted content preview for the detail view. + + Args: + obj (Message): The message instance. + + Returns: + str: HTML formatted content preview. + """ + content = obj.content.replace('\n', '
') + return format_html( + '
{}
', + content + ) + + content_preview_formatted.short_description = "Content Preview" -# Register your models here. + def get_queryset(self, request): + """Optimize queryset for the admin interface. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related( + 'sender', + 'receiver', + 'lobby' + ).prefetch_related( + 'sender__sent_messages', + 'receiver__received_messages' + ) + + def get_readonly_fields(self, request, obj=None): + """Dynamically determine readonly fields based on user permissions. + + Args: + request: The HTTP request object. + obj (Message, optional): The message object being edited. + + Returns: + tuple: Fields that should be readonly for this user/object. + """ + readonly = list(self.readonly_fields) + + # Non-superusers cannot edit core message data + if not request.user.is_superuser: + readonly.extend(['sender', 'receiver', 'lobby', 'content']) + + return readonly + + def has_delete_permission(self, request, obj=None): + """Control delete permissions for message objects. + + Args: + request: The HTTP request object. + obj (Message, optional): The message object being considered for deletion. + + Returns: + bool: True if the user can delete messages, False otherwise. + """ + # Only superusers can delete messages + return request.user.is_superuser + + def mark_as_reviewed(self, request, queryset): + """Custom admin action to mark messages as reviewed. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected messages. + """ + count = queryset.count() + self.message_user( + request, + f"Marked {count} message(s) as reviewed. " + f"This action is logged for audit purposes." + ) + + mark_as_reviewed.short_description = "Mark selected messages as reviewed" + + def export_conversation(self, request, queryset): + """Custom admin action to export conversation data. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected messages. + """ + count = queryset.count() + self.message_user( + request, + f"Export initiated for {count} message(s). " + f"Download link will be provided when processing is complete." + ) + + export_conversation.short_description = "Export selected messages" + + def delete_selected_messages(self, request, queryset): + """Custom admin action for safe message deletion. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected messages. + """ + if not request.user.is_superuser: + self.message_user( + request, + "Only superusers can delete messages.", + level='ERROR' + ) + return + + count = queryset.count() + queryset.delete() + self.message_user( + request, + f"Successfully deleted {count} message(s). " + f"This action has been logged for audit purposes." + ) + + delete_selected_messages.short_description = "Delete selected messages (Superuser only)" + + def save_model(self, request, obj, form, change): + """Custom save logic for message objects. + + Args: + request: The HTTP request object. + obj (Message): The message object being saved. + form: The admin form instance. + change (bool): True if this is an update, False if creating new. + """ + if not 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/management/__init__.py b/chat/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py new file mode 100644 index 0000000..b72eb32 --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.6 on 2025-10-06 19:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('game', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('content', models.TextField()), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ('lobby', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='game.lobby')), + ('receiver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/chat/migrations/0002_alter_message_options.py b/chat/migrations/0002_alter_message_options.py new file mode 100644 index 0000000..49db07d --- /dev/null +++ b/chat/migrations/0002_alter_message_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='message', + options={'ordering': ['-sent_at'], 'verbose_name': 'Message', 'verbose_name_plural': 'Messages'}, + ), + ] diff --git a/chat/migrations/0003_alter_message_lobby_and_more.py b/chat/migrations/0003_alter_message_lobby_and_more.py new file mode 100644 index 0000000..3a2b86b --- /dev/null +++ b/chat/migrations/0003_alter_message_lobby_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0002_alter_message_options'), + ('game', '0003_turn_move'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='message', + name='lobby', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='game.lobby'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['lobby', '-sent_at'], name='chat_messag_lobby_i_96d6b6_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['sender', 'receiver', '-sent_at'], name='chat_messag_sender__ba5b4a_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['-sent_at'], name='chat_messag_sent_at_67fe48_idx'), + ), + ] diff --git a/chat/models.py b/chat/models.py index 71a8362..ecd609e 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,3 +1,162 @@ +"""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. +""" + +import uuid from django.db import models -# Create your models here. + +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 = 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) + + 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. + + Returns: + bool: True if message has a receiver (private), False if lobby message. + """ + return self.receiver is not None + + def is_lobby_message(self): + """Check if this is a lobby/group message. + + Returns: + bool: True if message belongs to a lobby, False if private message. + """ + return self.lobby is not None + + def get_chat_context(self): + """Get the context (lobby or private chat) for this message. + + Returns: + dict: Dictionary with context type and relevant object. + """ + if self.lobby: + return { + 'type': 'lobby', + 'context': self.lobby, + 'context_name': self.lobby.name + } + elif self.receiver: + return { + 'type': 'private', + 'context': self.receiver, + 'context_name': f"Private chat with {self.receiver.username}" + } + return {'type': 'unknown', 'context': None, 'context_name': 'Unknown'} + + @classmethod + def get_lobby_messages(cls, lobby, limit=50): + """Get recent messages for a specific lobby. + + Args: + lobby (Lobby): The lobby to get messages for. + limit (int): Maximum number of messages to retrieve. + + Returns: + QuerySet: Recent messages in the lobby. + """ + return cls.objects.filter(lobby=lobby).order_by('-sent_at')[:limit] + + @classmethod + def get_private_conversation(cls, user1, user2, limit=50): + """Get recent private messages between two users. + + Args: + user1 (User): First user in the conversation. + user2 (User): Second user in the conversation. + limit (int): Maximum number of messages to retrieve. + + Returns: + QuerySet: Recent messages between the users. + """ + return cls.objects.filter( + models.Q(sender=user1, receiver=user2) | + models.Q(sender=user2, receiver=user1), + lobby__isnull=True + ).order_by('-sent_at')[:limit] + + def clean(self): + """Validate that message has either lobby or receiver, but not both. + + Raises: + ValidationError: If both lobby and receiver are set, or if neither is set. + """ + from django.core.exceptions import ValidationError + + if self.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. + """ + self.clean() + super().save(*args, **kwargs) + + 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=['-sent_at']), + ] diff --git a/chat/tests.py b/chat/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/chat/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/chat/tests/__init__.py b/chat/tests/__init__.py new file mode 100644 index 0000000..bdb872f --- /dev/null +++ b/chat/tests/__init__.py @@ -0,0 +1,4 @@ +"""Test suite for game app. + +This package contains comprehensive tests for all chat-related models, +""" diff --git a/chat/tests/test_models.py b/chat/tests/test_models.py new file mode 100644 index 0000000..2d9d2f1 --- /dev/null +++ b/chat/tests/test_models.py @@ -0,0 +1,115 @@ +"""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_queries.py b/chat/tests/test_queries.py new file mode 100644 index 0000000..7d21aa4 --- /dev/null +++ b/chat/tests/test_queries.py @@ -0,0 +1,90 @@ +"""Tests for query methods on the Message model.""" + +import pytest +from chat.models import Message +from game.models import Lobby + + +@pytest.mark.django_db +class TestMessageQueries: + """Test suite for class methods on Message that perform queries.""" + + @pytest.fixture(autouse=True) + def set_up(self, test_user, second_user, basic_lobby): + """Sets up users and a lobby for the tests.""" + self.user1 = test_user + self.user2 = second_user + self.lobby = basic_lobby + + 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)) + 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.""" + for i in range(5): + Message.objects.create( + sender=self.user1, lobby=self.lobby, content=f"Msg {i}" + ) + + messages = list(Message.get_lobby_messages(self.lobby, limit=3)) + assert len(messages) == 3 + + def test_get_lobby_messages_empty(self, lobby_factory): + """Tests get_lobby_messages() for a lobby with no messages.""" + empty_lobby = lobby_factory(owner=self.user1, name="Empty") + messages = list(Message.get_lobby_messages(empty_lobby)) + 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") + + messages = list(Message.get_private_conversation(self.user1, self.user2)) + 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)) + assert messages[0] == msg2 + assert messages[1] == msg1 + + def test_get_private_conversation_limit(self): + """Tests get_private_conversation() respects the limit parameter.""" + for i in range(5): + Message.objects.create( + sender=self.user1, receiver=self.user2, content=f"Msg {i}" + ) + + messages = list(Message.get_private_conversation(self.user1, self.user2, limit=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 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..1a1b3b4 --- /dev/null +++ b/conftest.py @@ -0,0 +1,365 @@ +""" +Fixtures for testing the Durak card game Django application. + +This module provides reusable pytest fixtures for creating users, cards, +lobbies, games, and special cards/rule sets. The fixtures include both +factory-style functions (for flexible object creation) and pre-defined +instances for common test scenarios. + +Usage: + - Import the fixtures in your test modules. + - Use factory fixtures to create objects with custom attributes. + - Use pre-defined fixtures for simple, ready-to-use test objects. + +Examples: + def test_user_has_avatar(test_user): + assert not test_user.has_avatar() + + def test_basic_game_has_trump(basic_game, basic_cards): + assert basic_game.trump_card == basic_cards['ace_hearts'] +""" + +import os +import pytest + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.settings") + +import django + +django.setup() + +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from game.models import ( + CardSuit, CardRank, Card, Lobby, LobbySettings, + Game, GamePlayer, SpecialCard, SpecialRuleSet +) + +User = get_user_model() + + +@pytest.fixture +def user_factory(db): + """Factory fixture for creating users with custom attributes safely (no IntegrityError). + + Returns: + callable: Function that creates and returns a User instance. + Args: + username (str): Username for the user. Defaults to 'testuser'. + password (str): Password for the user. Defaults to 'test123'. + email (str, optional): Email for the user. + **kwargs: Additional fields to set on the user. + + Example: + user = user_factory(username='player1', email='player1@test.com') + """ + + counter = {'i': 0} + + def create_user(username=None, password="test123", **kwargs): + counter['i'] += 1 + if username is None: + username = f"player{counter['i']}" + kwargs.setdefault('email', f'{username}@example.com') + + # if user already exists, return it + User = get_user_model() + existing_user = User.objects.filter(username=username).first() + if existing_user: + return existing_user + + # else create a new user + return User.objects.create_user(username=username, password=password, **kwargs) + + return create_user + + +@pytest.fixture +def test_user(user_factory): + """Create a default test user. + + Returns: + User: A user instance with username 'player1'. + """ + return user_factory(username="player1", email="player1@example.com") + + +@pytest.fixture +def second_user(user_factory): + """Create a second test user. + + Returns: + User: A user instance with username 'player2'. + """ + return user_factory(username="player2", email="player2@example.com") + + +@pytest.fixture +def card_suits(): + """Create basic card suits for testing.""" + return { + 'hearts': CardSuit.objects.create(name="Hearts", color="red"), + 'spades': CardSuit.objects.create(name="Spades", color="black"), + 'diamonds': CardSuit.objects.create(name="Diamonds", color="red"), + 'clubs': CardSuit.objects.create(name="Clubs", color="black"), + } + + +@pytest.fixture +def card_ranks(): + """Create basic card ranks for testing.""" + return { + 'ace': CardRank.objects.create(name="Ace", value=14), + 'king': CardRank.objects.create(name="King", value=13), + 'queen': CardRank.objects.create(name="Queen", value=12), + 'jack': CardRank.objects.create(name="Jack", value=11), + 'ten': CardRank.objects.create(name="Ten", value=10), + 'seven': CardRank.objects.create(name="Seven", value=7), + 'six': CardRank.objects.create(name="Six", value=6), + } + + +@pytest.fixture +def basic_cards(card_suits, card_ranks): + """Create basic cards for testing.""" + return { + 'ace_hearts': Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ), + 'seven_hearts': Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ), + 'king_spades': Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ), + 'six_diamonds': Card.objects.create( + suit=card_suits['diamonds'], + rank=card_ranks['six'] + ), + } + + +@pytest.fixture +def lobby_factory(): + """Factory fixture for creating lobbies with customizable settings. + + Returns: + callable: Function that creates and returns a Lobby instance with settings. + Args: + owner (User): The user who owns the lobby. + name (str): Name of the lobby. Defaults to 'Test Lobby'. + status (str): Lobby status. Defaults to 'waiting'. + **kwargs: Additional lobby and settings parameters: + - is_private (bool): Whether lobby is private. + - password_hash (str): Hashed password for private lobbies. + - max_players (int): Maximum number of players. + - card_count (int): Number of cards in deck (24, 36, or 52). + - is_transferable (bool): Allow card transfers. + - neighbor_throw_only (bool): Restrict throws to neighbors. + - allow_jokers (bool): Include jokers in deck. + - turn_time_limit (int): Time limit per turn in seconds. + - special_rule_set (SpecialRuleSet): Special rules to apply. + + Example: + lobby = lobby_factory( + owner=user, + name='Pro Game', + max_players=6, + is_transferable=True + ) + """ + + def create_lobby(owner, name="Test Lobby", status='waiting', **kwargs): + lobby = Lobby.objects.create( + owner=owner, + name=name, + status=status, + is_private=kwargs.get('is_private', False), + password_hash=kwargs.get('password_hash', None) + ) + + # Automatically create lobby settings with provided or default values + LobbySettings.objects.create( + lobby=lobby, + max_players=kwargs.get('max_players', 4), + card_count=kwargs.get('card_count', 36), + is_transferable=kwargs.get('is_transferable', False), + neighbor_throw_only=kwargs.get('neighbor_throw_only', False), + allow_jokers=kwargs.get('allow_jokers', False), + turn_time_limit=kwargs.get('turn_time_limit', None), + special_rule_set=kwargs.get('special_rule_set', None) + ) + + return lobby + + return create_lobby + + +@pytest.fixture +def basic_lobby(test_user, lobby_factory): + """Create a basic lobby for testing. + + Args: + test_user: Fixture providing a default test user. + lobby_factory: Fixture providing lobby creation function. + + Returns: + Lobby: A lobby instance with default settings. + """ + return lobby_factory(owner=test_user) + + +@pytest.fixture +def game_factory(db): + """Factory fixture for creating game instances. + + Returns: + callable: Function that creates and returns a Game instance. + Args: + lobby (Lobby): The lobby associated with this game. + trump_card (Card): The trump card for this game. + status (str): Game status. Defaults to 'in_progress'. + **kwargs: Additional game parameters: + - loser (User): The losing player (for finished games). + - finished_at (datetime): When the game finished. + + Example: + game = game_factory( + lobby=lobby, + trump_card=ace_of_hearts, + status='in_progress' + ) + """ + + def create_game(lobby, trump_card, status='in_progress', **kwargs): + return Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status=status, + loser=kwargs.get('loser', None), + finished_at=kwargs.get('finished_at', None) + ) + + return create_game + + +@pytest.fixture +def basic_game(basic_lobby, basic_cards, game_factory): + """Create a basic game ready for testing. + + Args: + basic_lobby: Fixture providing a basic lobby. + basic_cards: Fixture providing basic card instances. + game_factory: Fixture providing game creation function. + + Returns: + Game: A game instance in 'in_progress' status with ace of hearts as trump. + + Note: + The associated lobby's status is changed to 'playing'. + """ + basic_lobby.status = 'playing' + basic_lobby.save() + return game_factory( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'] + ) + + +@pytest.fixture +def game_player_factory(db): + """Factory fixture for creating game player instances. + + Returns: + callable: Function that creates and returns a GamePlayer instance. + Args: + game (Game): The game instance. + user (User): The player's user instance. + seat_position (int): Player's seat position (1-based). + cards_remaining (int): Number of cards in player's hand. + + Example: + player = game_player_factory( + game=game, + user=user, + seat_position=1, + cards_remaining=6 + ) + """ + + def create_game_player(game, user, seat_position, cards_remaining=6): + return GamePlayer.objects.create( + game=game, + user=user, + seat_position=seat_position, + cards_remaining=cards_remaining + ) + + return create_game_player + + +@pytest.fixture +def special_card_skip(db): + """Create a special card with skip effect. + + Returns: + SpecialCard: A special card that skips the next player's turn. + """ + return SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + effect_value={}, + description="Next player loses their turn" + ) + + +@pytest.fixture +def special_card_draw(db): + """Create a special card with draw effect. + + Returns: + SpecialCard: A special card that makes target draw 2 cards. + """ + return SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + effect_value={"card_count": 2}, + description="Target draws cards" + ) + + +@pytest.fixture +def special_card_reverse(db): + """Create a special card with reverse effect. + + Returns: + SpecialCard: A special card that reverses turn order. + """ + return SpecialCard.objects.create( + name="Reverse", + effect_type="reverse", + effect_value={}, + description="Reverse turn order" + ) + + +@pytest.fixture +def basic_rule_set(db): + """Create a basic special rule set for testing. + + Returns: + SpecialRuleSet: A rule set with minimum 2 players requirement. + """ + return SpecialRuleSet.objects.create( + name="Beginner Special", + description="Simple special cards for new players", + min_players=2 + ) + +@pytest.fixture +def api_client(): + """DRF APIClient for API-tests.""" + return APIClient() diff --git a/game/admin.py b/game/admin.py index 8c38f3f..9176f49 100644 --- a/game/admin.py +++ b/game/admin.py @@ -1,3 +1,2109 @@ +"""Admin configuration for the game system of the Durak card game application. + +This module defines the Django admin interface configuration for all models +in the game app, providing comprehensive management tools for administrators +to monitor and manage game functionality, lobbies, players, and card mechanics. +""" + +import json from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta +from django.db import models +from django.core.exceptions import ValidationError +from .models import ( + CardSuit, CardRank, Lobby, LobbySettings, LobbyPlayer, Game, GamePlayer, + Card, SpecialCard, SpecialRuleSet, SpecialRuleSetCard, GameDeck, + PlayerHand, TableCard, DiscardPile, Turn, Move +) + + +@admin.register(CardSuit) +class CardSuitAdmin(admin.ModelAdmin): + """Admin interface for the CardSuit model. + + Provides management capabilities for playing card suits with visual indicators + and efficient display for quick suit identification and management. + + Features: + - Color-coded suit display with emojis + - Filtering by color (red/black) + - Search by suit name + - Readonly ID field + - Optimized for small dataset + + Attributes: + list_display: Fields shown in the suit list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering for the suit list + """ + + list_display = ('suit_display', 'color_display', 'id') + list_filter = ('color',) + search_fields = ('name',) + readonly_fields = ('id',) + ordering = ('name',) + + def suit_display(self, obj): + """Display suit name with appropriate emoji indicator. + + Args: + obj (CardSuit): The card suit instance. + + Returns: + str: HTML formatted suit name with emoji. + """ + emoji_map = { + 'Hearts': '♥️', + 'Diamonds': '♦️', + 'Clubs': '♣️', + 'Spades': '♠️' + } + emoji = emoji_map.get(obj.name, '🃏') + return format_html('{} {}', emoji, obj.name) + + suit_display.short_description = "Suit" + suit_display.admin_order_field = 'name' + + def color_display(self, obj): + """Display suit color with visual indicator. + + Args: + obj (CardSuit): The card suit instance. + + Returns: + str: HTML formatted color with styling. + """ + if obj.color == 'red': + return format_html('🔴 Red') + else: + return format_html('⚫ Black') + + color_display.short_description = "Color" + color_display.admin_order_field = 'color' + + +@admin.register(CardRank) +class CardRankAdmin(admin.ModelAdmin): + """Admin interface for the CardRank model. + + Provides management capabilities for playing card ranks with value-based + ordering and face card identification for game logic management. + + Features: + - Value-based display with face card indicators + - Ordering by numeric value + - Quick identification of face cards + - Search by rank name + - Readonly fields for system data + + Attributes: + list_display: Fields shown in the rank list view + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by rank value + """ + + list_display = ('rank_display', 'value', 'card_type_display', 'id') + search_fields = ('name',) + readonly_fields = ('id',) + ordering = ('value',) + + def rank_display(self, obj): + """Display rank name with value information. + + Args: + obj (CardRank): The card rank instance. + + Returns: + str: Formatted rank name with value. + """ + return f"{obj.name} ({obj.value})" + + rank_display.short_description = "Rank" + rank_display.admin_order_field = 'value' + + def card_type_display(self, obj): + """Display whether this is a face card or number card. + + Args: + obj (CardRank): The card rank instance. + + Returns: + str: HTML formatted card type indicator. + """ + if obj.is_face_card(): + return format_html('👑 Face Card') + else: + return format_html('🔢 Number Card') + + card_type_display.short_description = "Type" + + +@admin.register(Lobby) +class LobbyAdmin(admin.ModelAdmin): + """Admin interface for the Lobby model. + + Provides comprehensive management capabilities for game lobbies including + player management, game status tracking, and lobby configuration. + + Features: + - Visual status indicators with privacy settings + - Player count tracking and capacity management + - Advanced filtering by status, privacy, and creation date + - Direct access to lobby settings and players + - Custom actions for lobby management + - Enhanced search across owners and lobby names + + Attributes: + list_display: Fields shown in the lobby list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + date_hierarchy: Date-based navigation + ordering: Default ordering for the lobby list + fieldsets: Organization of fields in the detail view + inlines: Inline editing of related objects + actions: Custom bulk actions available + """ + + list_display = ( + 'lobby_info_display', + 'owner', + 'status_display', + 'privacy_display', + 'player_count_display', + 'created_at_formatted', + 'can_start_display' + ) + + list_display_links = ('lobby_info_display',) + + list_filter = ( + 'status', + 'is_private', + 'created_at', + ('owner', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'name', + 'owner__username', + 'owner__email', + ) + + readonly_fields = ( + 'id', + 'created_at', + 'password_hash', + 'player_count_display', + 'can_start_display', + 'lobby_statistics' + ) + + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + fieldsets = ( + ('Lobby Information', { + 'fields': ('id', 'name', 'owner'), + 'description': 'Basic lobby identification and ownership.' + }), + ('Privacy & Access', { + 'fields': ('is_private', 'password_hash'), + 'description': 'Privacy settings and access control.', + 'classes': ('collapse',) + }), + ('Game Status', { + 'fields': ('status', 'player_count_display', 'can_start_display'), + 'description': 'Current lobby state and game readiness.' + }), + ('Statistics', { + 'fields': ('lobby_statistics',), + 'description': 'Detailed lobby activity statistics.', + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at',), + 'description': 'System-generated timing information.', + 'classes': ('collapse',) + }), + ) + + actions = ['close_lobbies', 'reset_lobby_status', 'export_lobby_data'] + + def lobby_info_display(self, obj): + """Display lobby name with ID for identification. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Formatted lobby name with truncated ID. + """ + short_id = str(obj.id)[:8] + return f"{obj.name} ({short_id}...)" + + lobby_info_display.short_description = "Lobby" + lobby_info_display.admin_order_field = 'name' + + def status_display(self, obj): + """Display lobby status with visual indicators. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: HTML formatted status with color coding. + """ + status_colors = { + 'waiting': '#28a745', + 'playing': '#007bff', + 'closed': '#6c757d' + } + status_icons = { + 'waiting': '⏳', + 'playing': '🎮', + 'closed': '🔒' + } + + color = status_colors.get(obj.status, '#6c757d') + icon = status_icons.get(obj.status, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_status_display() + ) + + status_display.short_description = "Status" + status_display.admin_order_field = 'status' + + def privacy_display(self, obj): + """Display privacy setting with visual indicator. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: HTML formatted privacy status. + """ + if obj.is_private: + return format_html('🔐 Private') + else: + return format_html('🌐 Public') + + privacy_display.short_description = "Privacy" + privacy_display.admin_order_field = 'is_private' + + def player_count_display(self, obj): + """Display current player count with capacity information. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Player count with capacity and status indicators. + """ + try: + current_count = obj.get_active_players().count() + max_count = obj.settings.max_players + ready_count = obj.players.filter(status='ready').count() + + if current_count >= max_count: + color = '#dc3545' # Red for full + status = '🔴 Full' + elif current_count == 0: + color = '#6c757d' # Gray for empty + status = '⚪ Empty' + else: + color = '#28a745' # Green for available + status = '🟢 Available' + + return format_html( + '{} {}/{}
' + '({} ready)', + color, status, current_count, max_count, ready_count + ) + except: + return format_html('❌ Error') + + player_count_display.short_description = "Players" + + def created_at_formatted(self, obj): + """Display formatted creation timestamp with relative time. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Formatted datetime with relative time indicator. + """ + now = timezone.now() + time_diff = now - obj.created_at + + if time_diff < timedelta(hours=1): + minutes = int(time_diff.total_seconds() / 60) + relative = f"{minutes}m ago" + elif time_diff < timedelta(days=1): + hours = int(time_diff.total_seconds() / 3600) + relative = f"{hours}h ago" + else: + days = time_diff.days + relative = f"{days}d ago" + + return format_html( + '{}
({})}', + obj.created_at.strftime('%Y-%m-%d %H:%M'), + relative + ) + + created_at_formatted.short_description = "Created" + created_at_formatted.admin_order_field = 'created_at' + + def can_start_display(self, obj): + """Display whether the lobby can start a game. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Visual indicator for game start readiness. + """ + if obj.can_start_game(): + return format_html('✅ Ready') + else: + return format_html('❌ Not Ready') + + can_start_display.short_description = "Can Start" + + def lobby_statistics(self, obj): + """Display comprehensive lobby statistics. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: HTML formatted statistics summary. + """ + try: + total_players = obj.players.count() + active_players = obj.get_active_players().count() + games_played = obj.game_set.count() + messages_sent = obj.messages.count() + + return format_html( + '
' + 'Statistics:
' + 'Total Players Joined: {}
' + 'Currently Active: {}
' + 'Games Played: {}
' + 'Messages Sent: {}
' + '
', + total_players, active_players, games_played, messages_sent + ) + except: + return format_html('Statistics unavailable') + + lobby_statistics.short_description = "Statistics" + + def get_queryset(self, request): + """Optimize queryset for the admin interface. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related( + 'owner', + 'settings' + ).prefetch_related( + 'players', + 'game_set', + 'messages' + ) + + def close_lobbies(self, request, queryset): + """Custom admin action to close selected lobbies. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobbies. + """ + updated = queryset.filter(status__in=['waiting', 'playing']).update(status='closed') + self.message_user( + request, + f"Closed {updated} lobby(ies). Active games may continue." + ) + + close_lobbies.short_description = "Close selected lobbies" + + def reset_lobby_status(self, request, queryset): + """Custom admin action to reset lobby status to waiting. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobbies. + """ + if not request.user.is_superuser: + self.message_user(request, "Only superusers can reset lobby status.", level='ERROR') + return + + updated = queryset.update(status='waiting') + self.message_user(request, f"Reset {updated} lobby(ies) to waiting status.") + + reset_lobby_status.short_description = "Reset to waiting status (Superuser only)" + + def export_lobby_data(self, request, queryset): + """Custom admin action to export lobby data. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobbies. + """ + count = queryset.count() + self.message_user( + request, + f"Export initiated for {count} lobby(ies). Download link will be provided when ready." + ) + + export_lobby_data.short_description = "Export lobby data" + + +class LobbyPlayerInline(admin.TabularInline): + """Inline admin interface for LobbyPlayer model within Lobby admin. + + Provides a compact view of players within a lobby directly from + the lobby admin page, allowing quick player management. + """ + model = LobbyPlayer + extra = 0 + readonly_fields = ('id', 'status') + fields = ('user', 'status', 'id') + + +@admin.register(LobbySettings) +class LobbySettingsAdmin(admin.ModelAdmin): + """Admin interface for the LobbySettings model. + + Provides management capabilities for lobby game configuration settings + with validation and rule compatibility checking. + + Features: + - Configuration overview with rule compatibility + - Settings validation and beginner-friendly indicators + - Direct lobby access links + - Custom validation for settings combinations + - Readonly fields for computed properties + + Attributes: + list_display: Fields shown in the settings list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + fieldsets: Organization of fields in the detail view + """ + + list_display = ( + 'settings_summary', + 'lobby_link', + 'configuration_display', + 'compatibility_display', + 'beginner_friendly_display' + ) + + list_display_links = ('settings_summary',) + + list_filter = ( + 'max_players', + 'card_count', + 'is_transferable', + 'neighbor_throw_only', + 'allow_jokers', + ('special_rule_set', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'lobby__name', + 'lobby__owner__username', + ) + + readonly_fields = ( + 'id', + 'beginner_friendly_display', + 'has_time_limit', + 'compatibility_display' + ) + + fieldsets = ( + ('Basic Settings', { + 'fields': ('id', 'lobby', 'max_players', 'card_count'), + 'description': 'Core game configuration parameters.' + }), + ('Game Rules', { + 'fields': ('is_transferable', 'neighbor_throw_only', 'allow_jokers'), + 'description': 'Gameplay rule modifications.' + }), + ('Advanced Configuration', { + 'fields': ('turn_time_limit', 'special_rule_set'), + 'description': 'Advanced settings and special rules.', + 'classes': ('collapse',) + }), + ('Compatibility Analysis', { + 'fields': ('beginner_friendly_display', 'compatibility_display'), + 'description': 'Automated analysis of settings compatibility.', + 'classes': ('collapse',) + }), + ) + + def settings_summary(self, obj): + """Display a summary of key settings. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: Formatted summary of main settings. + """ + return f"{obj.lobby.name} ({obj.card_count} cards, {obj.max_players} players)" + + settings_summary.short_description = "Settings" + settings_summary.admin_order_field = 'lobby__name' + + def lobby_link(self, obj): + """Display link to the associated lobby. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: HTML formatted link to lobby admin page. + """ + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html('🎯 {}', lobby_url, obj.lobby.name) + + lobby_link.short_description = "Lobby" + + def configuration_display(self, obj): + """Display configuration details with visual indicators. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: HTML formatted configuration summary. + """ + features = [] + + if obj.is_transferable: + features.append('🔄 Transferable') + if obj.neighbor_throw_only: + features.append('👥 Neighbor Only') + if obj.allow_jokers: + features.append('🃏 Jokers') + if obj.has_time_limit(): + features.append(f'⏱️ {obj.turn_time_limit}s') + + if not features: + return format_html('📋 Standard Rules') + + return format_html('
'.join(features)) + + configuration_display.short_description = "Configuration" + + def compatibility_display(self, obj): + """Display rule set compatibility information. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: HTML formatted compatibility status. + """ + if obj.special_rule_set: + if obj.special_rule_set.is_compatible_with_player_count(obj.max_players): + return format_html( + '✅ Compatible
' + '{}', + obj.special_rule_set.name + ) + else: + return format_html( + '❌ Incompatible
' + 'Requires {}+ players', + obj.special_rule_set.min_players + ) + else: + return format_html('📋 No Special Rules') + + compatibility_display.short_description = "Rule Compatibility" + + def beginner_friendly_display(self, obj): + """Display whether settings are beginner-friendly. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: Visual indicator for beginner-friendliness. + """ + if obj.is_beginner_friendly(): + return format_html('✅ Beginner Friendly') + else: + return format_html('⚠️ Advanced') + + beginner_friendly_display.short_description = "Difficulty" + + +# Добавим LobbyPlayerInline к LobbyAdmin +LobbyAdmin.inlines = [LobbyPlayerInline] + + +@admin.register(LobbyPlayer) +class LobbyPlayerAdmin(admin.ModelAdmin): + """Admin interface for the LobbyPlayer model. + + Provides management capabilities for player-lobby relationships + with status tracking and lobby navigation. + + Features: + - Player status management with visual indicators + - Direct links to user and lobby admin pages + - Status-based filtering and searching + - Bulk status update actions + - Activity tracking and management + + Attributes: + list_display: Fields shown in the lobby player list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering for the player list + actions: Custom bulk actions available + """ + + list_display = ( + 'player_info_display', + 'lobby_link', + 'status_display', + 'activity_display' + ) + + list_display_links = ('player_info_display',) + + list_filter = ( + 'status', + ('lobby', admin.RelatedOnlyFieldListFilter), + ('user', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'user__username', + 'user__email', + 'lobby__name', + ) + + readonly_fields = ('id',) + ordering = ('lobby__name', 'user__username') + + actions = ['mark_as_ready', 'mark_as_waiting', 'remove_from_lobby'] + + def player_info_display(self, obj): + """Display player information with user link. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: HTML formatted player info with admin link. + """ + user_url = reverse('admin:accounts_user_change', args=[obj.user.pk]) + return format_html('👤 {}', user_url, obj.user.username) + + player_info_display.short_description = "Player" + player_info_display.admin_order_field = 'user__username' + + def lobby_link(self, obj): + """Display link to the associated lobby. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: HTML formatted link to lobby admin page. + """ + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html('🎯 {}', lobby_url, obj.lobby.name) + + lobby_link.short_description = "Lobby" + lobby_link.admin_order_field = 'lobby__name' + + def status_display(self, obj): + """Display player status with visual indicators. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: HTML formatted status with color coding. + """ + status_colors = { + 'waiting': '#ffc107', + 'ready': '#28a745', + 'playing': '#007bff', + 'left': '#6c757d' + } + status_icons = { + 'waiting': '⏳', + 'ready': '✅', + 'playing': '🎮', + 'left': '👋' + } + + color = status_colors.get(obj.status, '#6c757d') + icon = status_icons.get(obj.status, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_status_display() + ) + + status_display.short_description = "Status" + status_display.admin_order_field = 'status' + + def activity_display(self, obj): + """Display player activity status. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: Visual indicator for player activity. + """ + if obj.is_active(): + return format_html('🟢 Active') + else: + return format_html('⚪ Inactive') + + activity_display.short_description = "Activity" + + def mark_as_ready(self, request, queryset): + """Custom admin action to mark players as ready. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobby players. + """ + updated = queryset.filter(status__in=['waiting']).update(status='ready') + self.message_user(request, f"Marked {updated} player(s) as ready.") + + mark_as_ready.short_description = "Mark selected players as ready" + + def mark_as_waiting(self, request, queryset): + """Custom admin action to mark players as waiting. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobby players. + """ + updated = queryset.filter(status__in=['ready']).update(status='waiting') + self.message_user(request, f"Marked {updated} player(s) as waiting.") + + mark_as_waiting.short_description = "Mark selected players as waiting" + + def remove_from_lobby(self, request, queryset): + """Custom admin action to remove players from lobbies. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobby players. + """ + if not request.user.is_superuser: + self.message_user(request, "Only superusers can remove players.", level='ERROR') + return + + updated = queryset.update(status='left') + self.message_user(request, f"Removed {updated} player(s) from their lobbies.") + + remove_from_lobby.short_description = "Remove from lobby (Superuser only)" + + +@admin.register(Game) +class GameAdmin(admin.ModelAdmin): + """Admin interface for the Game model. + + Provides comprehensive management capabilities for game sessions + with detailed tracking of game state, players, and statistics. + + Features: + - Game status tracking with visual indicators + - Player management and winner/loser identification + - Game duration and statistics display + - Trump card information with suit indicators + - Advanced filtering by lobby, status, and duration + - Custom actions for game management + + Attributes: + list_display: Fields shown in the game list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + date_hierarchy: Date-based navigation + ordering: Default ordering for the game list + fieldsets: Organization of fields in the detail view + actions: Custom bulk actions available + """ + + list_display = ( + 'game_info_display', + 'lobby_link', + 'status_display', + 'trump_card_display', + 'player_count_display', + 'duration_display', + 'winner_display' + ) + + list_display_links = ('game_info_display',) + + list_filter = ( + 'status', + 'started_at', + 'finished_at', + ('lobby', admin.RelatedOnlyFieldListFilter), + ('loser', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'lobby__name', + 'lobby__owner__username', + 'loser__username', + ) + + readonly_fields = ( + 'id', + 'started_at', + 'finished_at', + 'player_count_display', + 'duration_display', + 'winner_display', + 'game_statistics' + ) + + date_hierarchy = 'started_at' + ordering = ('-started_at',) + + fieldsets = ( + ('Game Information', { + 'fields': ('id', 'lobby', 'trump_card'), + 'description': 'Basic game identification and trump suit.' + }), + ('Game Status', { + 'fields': ('status', 'player_count_display', 'winner_display'), + 'description': 'Current game state and outcome information.' + }), + ('Timing Information', { + 'fields': ('started_at', 'finished_at', 'duration_display'), + 'description': 'Game duration and timing details.', + 'classes': ('collapse',) + }), + ('Game Results', { + 'fields': ('loser',), + 'description': 'Game outcome and losing player identification.' + }), + ('Statistics', { + 'fields': ('game_statistics',), + 'description': 'Detailed game statistics and analytics.', + 'classes': ('collapse',) + }), + ) + + actions = ['finish_games', 'export_game_data'] + + def game_info_display(self, obj): + """Display game information with ID. + + Args: + obj (Game): The game instance. + + Returns: + str: Formatted game info with truncated ID. + """ + short_id = str(obj.id)[:8] + return f"Game {short_id}..." + + game_info_display.short_description = "Game" + game_info_display.admin_order_field = 'started_at' + + def lobby_link(self, obj): + """Display link to the associated lobby. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted link to lobby admin page. + """ + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html('🎯 {}', lobby_url, obj.lobby.name) + + lobby_link.short_description = "Lobby" + lobby_link.admin_order_field = 'lobby__name' + + def status_display(self, obj): + """Display game status with visual indicators. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted status with color coding. + """ + if obj.status == 'in_progress': + return format_html('🎮 In Progress') + elif obj.status == 'finished': + return format_html('🏁 Finished') + else: + return format_html('❓ Unknown') + + status_display.short_description = "Status" + status_display.admin_order_field = 'status' + + def trump_card_display(self, obj): + """Display trump card information with suit indicator. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted trump card with suit emoji. + """ + suit_emojis = { + 'Hearts': '♥️', + 'Diamonds': '♦️', + 'Clubs': '♣️', + 'Spades': '♠️' + } + + suit_colors = { + 'Hearts': '#dc3545', + 'Diamonds': '#dc3545', + 'Clubs': '#212529', + 'Spades': '#212529' + } + + emoji = suit_emojis.get(obj.trump_card.suit.name, '🃏') + color = suit_colors.get(obj.trump_card.suit.name, '#6c757d') + + return format_html( + '{} {}
' + 'Trump: {} of {}', + color, emoji, obj.trump_card.suit.name, + obj.trump_card.rank.name, obj.trump_card.suit.name + ) + + trump_card_display.short_description = "Trump" + + def player_count_display(self, obj): + """Display number of players in the game. + + Args: + obj (Game): The game instance. + + Returns: + str: Player count with visual indicator. + """ + count = obj.get_player_count() + return format_html('👥 {} players', count) + + player_count_display.short_description = "Players" + + def duration_display(self, obj): + """Display game duration information. + + Args: + obj (Game): The game instance. + + Returns: + str: Formatted duration or current runtime. + """ + if obj.finished_at: + duration = obj.finished_at - obj.started_at + total_seconds = int(duration.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + if hours > 0: + return format_html('⏱️ {}h {}m {}s', hours, minutes, seconds) + elif minutes > 0: + return format_html('⏱️ {}m {}s', minutes, seconds) + else: + return format_html('⏱️ {}s', seconds) + else: + now = timezone.now() + runtime = now - obj.started_at + minutes = int(runtime.total_seconds() / 60) + return format_html('⏳ {}m (ongoing)', minutes) + + duration_display.short_description = "Duration" + + def winner_display(self, obj): + """Display game winner information. + + Args: + obj (Game): The game instance. + + Returns: + str: Winner information or game status. + """ + if obj.status == 'finished' and obj.loser: + winners = obj.get_winner() + if winners and winners.count() == 1: + winner = winners.first() + return format_html('🏆 {}', winner.user.username) + elif winners and winners.count() > 1: + return format_html('🏆 {} winners', winners.count()) + else: + return format_html('❓ Unknown') + elif obj.status == 'in_progress': + return format_html('🎮 In Progress') + else: + return format_html('⏳ Pending') + + winner_display.short_description = "Winner" + + def game_statistics(self, obj): + """Display comprehensive game statistics. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted statistics summary. + """ + try: + turns_count = obj.turns.count() + moves_count = Move.objects.filter(turn__game=obj).count() + cards_on_table = obj.tablecard_set.count() + cards_discarded = obj.discardpile_set.count() + + return format_html( + '
' + 'Game Statistics:
' + 'Total Turns: {}
' + 'Total Moves: {}
' + 'Cards on Table: {}
' + 'Cards Discarded: {}
' + '
', + turns_count, moves_count, cards_on_table, cards_discarded + ) + except: + return format_html('Statistics unavailable') + + game_statistics.short_description = "Statistics" + + def get_queryset(self, request): + """Optimize queryset for the admin interface. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related( + 'lobby', + 'lobby__owner', + 'trump_card', + 'trump_card__suit', + 'trump_card__rank', + 'loser' + ).prefetch_related( + 'players', + 'turns', + 'tablecard_set' + ) + + def finish_games(self, request, queryset): + """Custom admin action to finish selected games. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected games. + """ + if not request.user.is_superuser: + self.message_user(request, "Only superusers can finish games.", level='ERROR') + return + + updated = queryset.filter(status='in_progress').update( + status='finished', + finished_at=timezone.now() + ) + self.message_user(request, f"Finished {updated} game(s).") + + finish_games.short_description = "Finish selected games (Superuser only)" + + def export_game_data(self, request, queryset): + """Custom admin action to export game data. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected games. + """ + count = queryset.count() + self.message_user( + request, + f"Export initiated for {count} game(s). Download link will be provided when ready." + ) + + export_game_data.short_description = "Export game data" + + +@admin.register(GamePlayer) +class GamePlayerAdmin(admin.ModelAdmin): + """Admin interface for the GamePlayer model. + + Provides management capabilities for game player relationships + with card tracking and position management. + + Features: + - Player position and card count tracking + - Direct links to game and user admin pages + - Elimination status monitoring + - Seat position management + - Card count validation + + Attributes: + list_display: Fields shown in the game player list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by seat position + """ + + list_display = ( + 'player_info_display', + 'game_link', + 'seat_position', + 'cards_display', + 'status_display' + ) + + list_display_links = ('player_info_display',) + + list_filter = ( + 'seat_position', + 'cards_remaining', + ('game', admin.RelatedOnlyFieldListFilter), + ('user', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'user__username', + 'game__lobby__name', + ) + + readonly_fields = ('id',) + ordering = ('game', 'seat_position') + + def player_info_display(self, obj): + """Display player information with user link. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: HTML formatted player info with admin link. + """ + user_url = reverse('admin:accounts_user_change', args=[obj.user.pk]) + return format_html('👤 {}', user_url, obj.user.username) + + player_info_display.short_description = "Player" + player_info_display.admin_order_field = 'user__username' + + def game_link(self, obj): + """Display link to the associated game. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: HTML formatted link to game admin page. + """ + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('🎮 Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def cards_display(self, obj): + """Display card count with visual indicator. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: HTML formatted card count. + """ + if obj.cards_remaining == 0: + return format_html('🃏 0 cards (OUT)') + elif obj.cards_remaining <= 3: + return format_html('🃏 {} cards (LOW)', + obj.cards_remaining) + else: + return format_html('🃏 {} cards', obj.cards_remaining) + + cards_display.short_description = "Cards" + cards_display.admin_order_field = 'cards_remaining' + + def status_display(self, obj): + """Display player game status. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: Visual indicator for player status. + """ + if obj.is_eliminated(): + return format_html('✅ Eliminated') + else: + return format_html('🎮 Playing') + + status_display.short_description = "Status" + + +@admin.register(Card) +class CardAdmin(admin.ModelAdmin): + """Admin interface for the Card model. + + Provides management capabilities for playing cards with suit and rank + organization, special card identification, and game usage tracking. + + Features: + - Visual card representation with suit colors + - Special card effect indicators + - Suit and rank filtering + - Card usage statistics + - Trump card identification + - Search by card properties + + Attributes: + list_display: Fields shown in the card list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by suit and rank + fieldsets: Organization of fields in the detail view + """ + + list_display = ( + 'card_display', + 'suit_display', + 'rank_display', + 'special_display', + 'usage_stats' + ) + + list_display_links = ('card_display',) + + list_filter = ( + ('suit', admin.RelatedOnlyFieldListFilter), + ('rank', admin.RelatedOnlyFieldListFilter), + ('special_card', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'suit__name', + 'rank__name', + 'special_card__name', + ) + + readonly_fields = ('id', 'usage_stats', 'is_special') + ordering = ('suit__name', 'rank__value') + + fieldsets = ( + ('Card Properties', { + 'fields': ('id', 'suit', 'rank'), + 'description': 'Basic card identification.' + }), + ('Special Effects', { + 'fields': ('special_card', 'is_special'), + 'description': 'Special card abilities and effects.', + 'classes': ('collapse',) + }), + ('Usage Statistics', { + 'fields': ('usage_stats',), + 'description': 'Card usage in games and trump selection.', + 'classes': ('collapse',) + }), + ) + + def card_display(self, obj): + """Display card with appropriate styling. + + Args: + obj (Card): The card instance. + + Returns: + str: HTML formatted card representation. + """ + suit_emojis = { + 'Hearts': '♥️', + 'Diamonds': '♦️', + 'Clubs': '♣️', + 'Spades': '♠️' + } + + emoji = suit_emojis.get(obj.suit.name, '🃏') + + if obj.is_special(): + return format_html( + '' + '{} {} of {}', + emoji, obj.rank.name, obj.suit.name + ) + else: + return format_html('{} {} of {}', emoji, obj.rank.name, obj.suit.name) + + card_display.short_description = "Card" + card_display.admin_order_field = 'rank__value' + + def suit_display(self, obj): + """Display suit with color styling. + + Args: + obj (Card): The card instance. + + Returns: + str: HTML formatted suit display. + """ + if obj.suit.is_red(): + return format_html('{}', obj.suit.name) + else: + return format_html('{}', obj.suit.name) + + suit_display.short_description = "Suit" + suit_display.admin_order_field = 'suit__name' + + def rank_display(self, obj): + """Display rank with value information. + + Args: + obj (Card): The card instance. + + Returns: + str: Formatted rank with value. + """ + if obj.rank.is_face_card(): + return format_html( + '{} ({})', + obj.rank.name, obj.rank.value + ) + else: + return f"{obj.rank.name} ({obj.rank.value})" + + rank_display.short_description = "Rank" + rank_display.admin_order_field = 'rank__value' + + def special_display(self, obj): + """Display special card information. + + Args: + obj (Card): The card instance. + + Returns: + str: Special card indicator or standard card label. + """ + if obj.is_special(): + return format_html( + '⭐ {}', + obj.special_card.name + ) + else: + return format_html('📋 Standard') + + special_display.short_description = "Special" + + def usage_stats(self, obj): + """Display card usage statistics. + + Args: + obj (Card): The card instance. + + Returns: + str: HTML formatted usage statistics. + """ + try: + trump_count = obj.as_trump.count() + attack_count = obj.attack_card.count() + defense_count = obj.defense_card.count() + + return format_html( + '
' + 'Usage:
' + 'Trump: {} times
' + 'Attack: {} times
' + 'Defense: {} times
' + '
', + trump_count, attack_count, defense_count + ) + except: + return format_html('Stats unavailable') + + usage_stats.short_description = "Usage" + + +@admin.register(SpecialCard) +class SpecialCardAdmin(admin.ModelAdmin): + """Admin interface for the SpecialCard model. + + Provides management capabilities for special card effects with + detailed effect configuration and rule set associations. + + Features: + - Effect type categorization and visualization + - JSON effect value display and editing + - Rule set compatibility tracking + - Effect description formatting + - Targetability and counter information + + Attributes: + list_display: Fields shown in the special card list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by name + fieldsets: Organization of fields in the detail view + """ + + list_display = ( + 'name', + 'effect_type_display', + 'effect_summary', + 'targetable_display', + 'counterable_display' + ) + + list_display_links = ('name',) + + list_filter = ( + 'effect_type', + ) + + search_fields = ( + 'name', + 'description', + ) + + readonly_fields = ( + 'id', + 'targetable_display', + 'counterable_display', + 'effect_summary' + ) + + ordering = ('name',) + + fieldsets = ( + ('Special Card Information', { + 'fields': ('id', 'name', 'description'), + 'description': 'Basic special card identification and description.' + }), + ('Effect Configuration', { + 'fields': ('effect_type', 'effect_value', 'effect_summary'), + 'description': 'Special effect type and parameters.' + }), + ('Effect Properties', { + 'fields': ('targetable_display', 'counterable_display'), + 'description': 'Effect behavior and interaction properties.', + 'classes': ('collapse',) + }), + ) + + def effect_type_display(self, obj): + """Display effect type with visual indicator. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: HTML formatted effect type. + """ + type_colors = { + 'skip': '#ffc107', + 'reverse': '#6f42c1', + 'draw': '#dc3545', + 'custom': '#17a2b8' + } + + type_icons = { + 'skip': '⏭️', + 'reverse': '🔄', + 'draw': '📥', + 'custom': '⚙️' + } + + color = type_colors.get(obj.effect_type, '#6c757d') + icon = type_icons.get(obj.effect_type, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_effect_type_display() + ) + + effect_type_display.short_description = "Effect Type" + effect_type_display.admin_order_field = 'effect_type' + + def effect_summary(self, obj): + """Display effect summary with parameters. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: HTML formatted effect summary. + """ + if obj.effect_value: + try: + params = json.dumps(obj.effect_value, indent=2) if obj.effect_value else '{}' + return format_html( + '
' + '{}

' + 'Parameters:
' + '
{}
' + '
', + obj.get_effect_description() or obj.description or 'No description', + params + ) + except: + return format_html('Invalid effect data') + else: + return obj.get_effect_description() or obj.description or 'No description' + + effect_summary.short_description = "Effect Summary" + + def targetable_display(self, obj): + """Display whether effect is targetable. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: Visual indicator for targetability. + """ + if obj.is_targetable(): + return format_html('🎯 Targetable') + else: + return format_html('🔄 Self-Affecting') + + targetable_display.short_description = "Targeting" + + def counterable_display(self, obj): + """Display whether effect can be countered. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: Visual indicator for counterability. + """ + if obj.can_be_countered(): + return format_html('🛡️ Counterable') + else: + return format_html('⚡ Absolute') + + counterable_display.short_description = "Countering" + + +@admin.register(SpecialRuleSet) +class SpecialRuleSetAdmin(admin.ModelAdmin): + """Admin interface for the SpecialRuleSet model. + + Provides management capabilities for special rule configurations + with compatibility checking and card association management. + + Features: + - Rule set compatibility analysis + - Special card count tracking + - Player requirement validation + - Lobby compatibility checking + - Inline card management + + Attributes: + list_display: Fields shown in the rule set list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by name + fieldsets: Organization of fields in the detail view + inlines: Inline editing of related objects + """ + + list_display = ( + 'name', + 'min_players', + 'card_count_display', + 'compatibility_summary' + ) + + list_display_links = ('name',) + + list_filter = ( + 'min_players', + ) + + search_fields = ( + 'name', + 'description', + ) + + readonly_fields = ( + 'id', + 'card_count_display', + 'compatibility_summary' + ) + + ordering = ('name',) + + fieldsets = ( + ('Rule Set Information', { + 'fields': ('id', 'name', 'description'), + 'description': 'Basic rule set identification and description.' + }), + ('Configuration', { + 'fields': ('min_players',), + 'description': 'Rule set requirements and limitations.' + }), + ('Statistics', { + 'fields': ('card_count_display', 'compatibility_summary'), + 'description': 'Rule set statistics and compatibility analysis.', + 'classes': ('collapse',) + }), + ) + + def card_count_display(self, obj): + """Display count of associated special cards. + + Args: + obj (SpecialRuleSet): The rule set instance. + + Returns: + str: HTML formatted card count. + """ + total_cards = obj.get_special_card_count() + enabled_cards = obj.get_enabled_special_cards().count() + + return format_html( + '🃏 {} cards
' + '({} enabled)', + total_cards, enabled_cards + ) + + card_count_display.short_description = "Special Cards" + + def compatibility_summary(self, obj): + """Display compatibility summary information. + + Args: + obj (SpecialRuleSet): The rule set instance. + + Returns: + str: HTML formatted compatibility information. + """ + try: + # Get lobbies using this rule set + using_lobbies = LobbySettings.objects.filter(special_rule_set=obj).count() + + return format_html( + '
' + 'Compatibility:
' + 'Min Players: {}
' + 'Used by {} lobbies
' + 'Special Cards: {} total, {} enabled
' + '
', + obj.min_players, using_lobbies, + obj.get_special_card_count(), + obj.get_enabled_special_cards().count() + ) + except: + return format_html('Compatibility data unavailable') + + compatibility_summary.short_description = "Compatibility" + + +class SpecialRuleSetCardInline(admin.TabularInline): + """Inline admin interface for SpecialRuleSetCard model within SpecialRuleSet admin. + + Provides a compact view of special cards within a rule set directly from + the rule set admin page, allowing quick card management and status toggling. + """ + model = SpecialRuleSetCard + extra = 0 + readonly_fields = ('id',) + fields = ('card', 'is_enabled', 'id') + + +# Добавим inline к SpecialRuleSetAdmin +SpecialRuleSetAdmin.inlines = [SpecialRuleSetCardInline] + + +@admin.register(SpecialRuleSetCard) +class SpecialRuleSetCardAdmin(admin.ModelAdmin): + """Admin interface for the SpecialRuleSetCard model. + + Provides management capabilities for special card associations within + rule sets with enable/disable functionality and game compatibility. + + Features: + - Rule set and card association management + - Enable/disable status tracking + - Game compatibility checking + - Bulk enable/disable actions + - Association filtering and search + + Attributes: + list_display: Fields shown in the association list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by rule set and card + actions: Custom bulk actions available + """ + + list_display = ( + 'association_display', + 'rule_set_link', + 'card_link', + 'status_display', + 'compatibility_display' + ) + + list_display_links = ('association_display',) + + list_filter = ( + 'is_enabled', + ('rule_set', admin.RelatedOnlyFieldListFilter), + ('card', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'rule_set__name', + 'card__name', + ) + + readonly_fields = ('id', 'compatibility_display') + ordering = ('rule_set__name', 'card__name') + + actions = ['enable_cards', 'disable_cards', 'toggle_status'] + + def association_display(self, obj): + """Display association summary. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: Formatted association summary. + """ + status = "✅" if obj.is_enabled else "❌" + return f"{status} {obj.card.name} in {obj.rule_set.name}" + + association_display.short_description = "Association" + + def rule_set_link(self, obj): + """Display link to the rule set. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: HTML formatted link to rule set admin page. + """ + rule_set_url = reverse('admin:game_specialruleset_change', args=[obj.rule_set.pk]) + return format_html('📋 {}', rule_set_url, obj.rule_set.name) + + rule_set_link.short_description = "Rule Set" + + def card_link(self, obj): + """Display link to the special card. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: HTML formatted link to special card admin page. + """ + card_url = reverse('admin:game_specialcard_change', args=[obj.card.pk]) + return format_html('⭐ {}', card_url, obj.card.name) + + card_link.short_description = "Special Card" + + def status_display(self, obj): + """Display enable/disable status. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: HTML formatted status indicator. + """ + if obj.is_enabled: + return format_html('✅ Enabled') + else: + return format_html('❌ Disabled') + + status_display.short_description = "Status" + status_display.admin_order_field = 'is_enabled' + + def compatibility_display(self, obj): + """Display game compatibility information. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: Compatibility status indicator. + """ + if obj.is_enabled: + return format_html('🎮 Available in Games') + else: + return format_html('🚫 Not Available') + + compatibility_display.short_description = "Game Compatibility" + + def enable_cards(self, request, queryset): + """Custom admin action to enable selected card associations. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected associations. + """ + updated = queryset.update(is_enabled=True) + self.message_user(request, f"Enabled {updated} special card(s) in rule set(s).") + + enable_cards.short_description = "Enable selected special cards" + + def disable_cards(self, request, queryset): + """Custom admin action to disable selected card associations. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected associations. + """ + updated = queryset.update(is_enabled=False) + self.message_user(request, f"Disabled {updated} special card(s) in rule set(s).") + + disable_cards.short_description = "Disable selected special cards" + + def toggle_status(self, request, queryset): + """Custom admin action to toggle enable/disable status. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected associations. + """ + for obj in queryset: + obj.toggle_enabled() + + count = queryset.count() + self.message_user(request, f"Toggled status for {count} special card association(s).") + + toggle_status.short_description = "Toggle enable/disable status" + + +# Регистрируем остальные модели с базовой конфигурацией +@admin.register(GameDeck) +class GameDeckAdmin(admin.ModelAdmin): + """Admin interface for the GameDeck model. + + Basic management interface for game deck cards with position tracking. + """ + list_display = ('card', 'game_link', 'position', 'is_last_card') + list_filter = (('game', admin.RelatedOnlyFieldListFilter),) + search_fields = ('card__rank__name', 'card__suit__name', 'game__lobby__name') + readonly_fields = ('id',) + ordering = ('game', 'position') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + +@admin.register(PlayerHand) +class PlayerHandAdmin(admin.ModelAdmin): + """Admin interface for the PlayerHand model. + + Basic management interface for player hand cards with ordering. + """ + list_display = ('player', 'card', 'game_link', 'order_in_hand') + list_filter = ( + ('game', admin.RelatedOnlyFieldListFilter), + ('player', admin.RelatedOnlyFieldListFilter), + ) + search_fields = ('player__username', 'card__rank__name', 'card__suit__name') + readonly_fields = ('id',) + ordering = ('game', 'player', 'order_in_hand') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + +@admin.register(TableCard) +class TableCardAdmin(admin.ModelAdmin): + """Admin interface for the TableCard model. + + Management interface for attack-defense card pairs on the game table. + """ + list_display = ('attack_card', 'defense_card', 'game_link', 'defended_status') + list_filter = ( + ('game', admin.RelatedOnlyFieldListFilter), + 'defense_card', + ) + search_fields = ('attack_card__rank__name', 'defense_card__rank__name', 'game__lobby__name') + readonly_fields = ('id',) + ordering = ('game', 'id') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def defended_status(self, obj): + if obj.is_defended(): + return format_html('🛡️ Defended') + else: + return format_html('⚔️ Undefended') + + defended_status.short_description = "Status" + + +@admin.register(DiscardPile) +class DiscardPileAdmin(admin.ModelAdmin): + """Admin interface for the DiscardPile model. + + Basic management interface for discarded cards. + """ + list_display = ('card', 'game_link', 'position') + list_filter = (('game', admin.RelatedOnlyFieldListFilter),) + search_fields = ('card__rank__name', 'card__suit__name', 'game__lobby__name') + readonly_fields = ('id',) + ordering = ('game', 'position') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + +@admin.register(Turn) +class TurnAdmin(admin.ModelAdmin): + """Admin interface for the Turn model. + + Management interface for game turns with move tracking. + """ + list_display = ('turn_number', 'player', 'game_link', 'move_count', 'completion_status') + list_filter = ( + ('game', admin.RelatedOnlyFieldListFilter), + ('player', admin.RelatedOnlyFieldListFilter), + ) + search_fields = ('player__username', 'game__lobby__name') + readonly_fields = ('id', 'move_count') + ordering = ('game', 'turn_number') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def move_count(self, obj): + return obj.moves.count() + + move_count.short_description = "Moves" + + def completion_status(self, obj): + if obj.is_complete(): + return format_html('✅ Complete') + else: + return format_html('⏳ Pending') + + completion_status.short_description = "Status" + + +@admin.register(Move) +class MoveAdmin(admin.ModelAdmin): + """Admin interface for the Move model. + + Management interface for individual player moves with action tracking. + """ + list_display = ('action_display', 'player_link', 'game_link', 'turn_number', 'created_at') + list_filter = ( + 'action_type', + 'created_at', + ('turn__game', admin.RelatedOnlyFieldListFilter), + ('turn__player', admin.RelatedOnlyFieldListFilter), + ) + search_fields = ('turn__player__username', 'turn__game__lobby__name') + readonly_fields = ('id', 'created_at', 'player_link', 'game_link', 'turn_number') + ordering = ('-created_at',) + date_hierarchy = 'created_at' + + def action_display(self, obj): + action_colors = { + 'attack': '#dc3545', + 'defend': '#28a745', + 'pickup': '#ffc107' + } + action_icons = { + 'attack': '⚔️', + 'defend': '🛡️', + 'pickup': '📥' + } + + color = action_colors.get(obj.action_type, '#6c757d') + icon = action_icons.get(obj.action_type, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_action_type_display() + ) + + action_display.short_description = "Action" + action_display.admin_order_field = 'action_type' + + def player_link(self, obj): + player_url = reverse('admin:accounts_user_change', args=[obj.turn.player.pk]) + return format_html('👤 {}', player_url, obj.turn.player.username) + + player_link.short_description = "Player" + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.turn.game.pk]) + short_id = str(obj.turn.game.id)[:8] + return format_html('🎮 Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def turn_number(self, obj): + return obj.turn.turn_number -# Register your models here. + turn_number.short_description = "Turn #" + turn_number.admin_order_field = 'turn__turn_number' \ No newline at end of file diff --git a/game/management/__init__.py b/game/management/__init__.py new file mode 100644 index 0000000..6547108 --- /dev/null +++ b/game/management/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for game app. + +This package contains management commands for game application. +""" diff --git a/game/management/commands/__init__.py b/game/management/commands/__init__.py new file mode 100644 index 0000000..6547108 --- /dev/null +++ b/game/management/commands/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for game app. + +This package contains management commands for game application. +""" diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py new file mode 100644 index 0000000..f65a191 --- /dev/null +++ b/game/management/commands/export_db.py @@ -0,0 +1,280 @@ +""" +Export database rows to a JSON file (Django serialization) with optional apps filter. + +This management command streams model instances into a JSON array in chunks to +avoid building one huge list in memory. It supports filtering exported models +by app label (--apps), excluding particular models (--exclude), pretty JSON +(--indent), natural key serialization, gzipped output and configurable +chunk sizes. + +Design notes +- The `handle` entry point is small and delegates to helper methods for I/O + and per-model streaming to follow single-responsibility and make unit + testing easier. +- The command avoids loading entire querysets into memory by iterating and + flushing chunks. + +Examples + python manage.py export_db + python manage.py export_db --apps game,auth --indent 2 -o backups/backup.json.gz +""" + +import os +import gzip +from typing import Optional, Set, List, Callable, Tuple + +from django.core.management.base import BaseCommand, CommandError +from django.apps import apps +from django.conf import settings +from django.core import serializers + + +EXCLUDE_MODEL_NAMES = { + "ContentType", + "Session", +} + + +class Command(BaseCommand): + """Export database rows to JSON using Django serializers. + + The implementation keeps `handle()` concise by delegating tasks to + small helpers like `_resolve_output_path`, `_open_output_stream`, and + `_serialize_chunk_and_write`. + """ + + help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + parser.add_argument( + "--output", + "-o", + help=( + "Output file path (relative paths are created inside BASE_DIR). " + "Use '-' for stdout. Use .gz to gzip." + ), + default="db_backups/backup.json", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to export (e.g. 'auth,game'). If omitted exports all apps.", + default=None, + ) + parser.add_argument( + "--exclude", + help="Comma-separated model names to exclude (ModelName or app_label.ModelName).", + default="", + ) + parser.add_argument( + "--indent", + type=int, + default=None, + help="JSON indent level (pass an integer). If omitted output is compact (single line).", + ) + parser.add_argument( + "--natural-foreign", + action="store_true", + help="Use natural foreign keys when serializing (if supported by models).", + ) + parser.add_argument( + "--natural-primary", + action="store_true", + help="Use natural primary keys when serializing (if supported).", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=1000, + help="Number of objects to serialize per chunk (memory / performance tuning).", + ) + + # ------------------------- + # Small helpers + # ------------------------- + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + if not apps_arg: + return None + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def _resolve_output_path(self, path: str) -> str: + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + return path + + def models_to_export(self, apps_filter: Optional[Set[str]], exclude_set: Set[str]) -> List[type]: + all_models = list(apps.get_models()) + models_to_export: List[type] = [] + for m in all_models: + full_name = f"{m._meta.app_label}.{m.__name__}" + if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: + continue + if apps_filter is not None and m._meta.app_label not in apps_filter: + continue + models_to_export.append(m) + return models_to_export + + def _open_output_stream(self, output: str) -> Tuple[Callable[[str], None], Callable[[], None], str]: + """Return (write_chunk, close_fn, output_display_name). + + write_chunk is a callable that accepts a string and writes to the + destination. close_fn closes the underlying file object when used. + output_display_name is used in the final status message. + """ + write_to_stdout = (output == "-") + if write_to_stdout: + write_chunk = lambda s: self.stdout.write(s) + close_fn = lambda: None + return write_chunk, close_fn, "stdout" + + # Ensure path exists and open the file handle + output_path = self._resolve_output_path(output) + if output_path.endswith(".gz"): + fh = gzip.open(output_path, "wt", encoding="utf-8") + else: + fh = open(output_path, "w", encoding="utf-8") + + def _write(s: str): + fh.write(s) + + def _close(): + try: + fh.close() + except Exception: + pass + + return _write, _close, output_path + + def _serialize_chunk_and_write( + self, + chunk: List[object], + indent: Optional[int], + use_nat_foreign: bool, + use_nat_primary: bool, + write_chunk: Callable[[str], None], + separator: str, + first_piece: bool, + compact: bool, + ) -> bool: + """Serialize a chunk and write its inner JSON array items to the stream. + + Returns True if any items were written (so caller can update first_piece). + """ + if not chunk: + return False + + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + if start == -1 or end == -1: + # Fallback: write whole serialization + inner = serialized + else: + inner = serialized[start + 1 : end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + return True + return False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + return True + return False + + # ------------------------- + # Main handler (delegates heavily) + # ------------------------- + def handle(self, *args, **options): + """Main command entry: collect models and stream them to JSON output.""" + output = options["output"] + apps_arg = options["apps"] + exclude_arg = options["exclude"] + indent = options["indent"] + use_nat_foreign = options["natural_foreign"] + use_nat_primary = options["natural_primary"] + chunk_size = options["chunk_size"] + + apps_filter = self._parse_apps_arg(apps_arg) + exclude_set = {x.strip() for x in exclude_arg.split(",") if x.strip()} if exclude_arg else set() + + models_to_export = self.models_to_export(apps_filter, exclude_set) + if not models_to_export: + raise CommandError("No models found to export (check --apps and --exclude).") + + write_chunk, close_fh, output_display = self._open_output_stream(output) + + total_objects = 0 + first_piece = True + + try: + # Prepare JSON array delimiters + if indent is not None: + write_chunk("[\n") + separator = ",\n" + closing = "\n]\n" + else: + write_chunk("[") + separator = "," + closing = "]" + + # Stream objects model-by-model in chunks + for model in models_to_export: + qs = model._default_manager.all().iterator() + chunk: List[object] = [] + for obj in qs: + chunk.append(obj) + if len(chunk) >= chunk_size: + wrote = self._serialize_chunk_and_write( + chunk, + indent, + use_nat_foreign, + use_nat_primary, + write_chunk, + separator, + first_piece, + compact=(indent is None), + ) + if wrote: + first_piece = False + total_objects += len(chunk) + chunk = [] + + # flush remaining + if chunk: + wrote = self._serialize_chunk_and_write( + chunk, + indent, + use_nat_foreign, + use_nat_primary, + write_chunk, + separator, + first_piece, + compact=(indent is None), + ) + if wrote: + first_piece = False + total_objects += len(chunk) + + # Finish JSON + write_chunk(closing) + finally: + close_fh() + + self.stdout.write(self.style.SUCCESS(f"Exported {total_objects} objects to {output_display}")) diff --git a/game/management/commands/generate_fake_games.py b/game/management/commands/generate_fake_games.py new file mode 100644 index 0000000..60e880e --- /dev/null +++ b/game/management/commands/generate_fake_games.py @@ -0,0 +1,524 @@ +""" +Generate synthetic Durak game data for UI & statistics testing. + +This command builds realistic game rows (lobbies, games, game players, hands, +game deck, turns, table cards, moves, and discard piles). It prefers to create +test users by invoking the `generate_test_users` management command (which you +said lives in the `accounts` app). The command will call that management +command with a marker group (`Test_Users`) so created accounts are easy and +safe to delete later. + +Usage: + python manage.py generate_fake_games --games 3 --players 4 --moves 30 --card-count 36 +""" + +import itertools +import random +import re +import uuid +from typing import List, Optional + +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model + +from game.models import ( + CardSuit, + CardRank, + Card, + Lobby, + LobbySettings, + LobbyPlayer, + Game, + GamePlayer, + GameDeck, + PlayerHand, + TableCard, + DiscardPile, + Turn, + Move, +) + +User = get_user_model() + +DEFAULT_RANKS_52 = [ + ("2", 2), + ("3", 3), + ("4", 4), + ("5", 5), + ("6", 6), + ("7", 7), + ("8", 8), + ("9", 9), + ("10", 10), + ("Jack", 11), + ("Queen", 12), + ("King", 13), + ("Ace", 14), +] + +DEFAULT_RANKS_36 = [r for r in DEFAULT_RANKS_52 if r[1] >= 6] +DEFAULT_RANKS_24 = [r for r in DEFAULT_RANKS_52 if r[1] >= 9] + + +class Command(BaseCommand): + """Management command to generate fake Durak games. + + The command produces decks, deals hands, creates games and simulates a + sequence of turns with Move rows (attack/defend/pickup). For test user + creation it prefers to call the `generate_test_users` command (from the + `accounts` app) with marker group `Test_Users`. If that call fails the + command falls back to inline user creation to avoid blocking generation. + + Options: + --games: Number of games to generate (default 5) + --players: Players per game (default 4) + --moves: Approximate number of moves per game (default 20) + --card-count: Deck size (24, 36 or 52, default 36) + --seed: Optional random seed + --reset: Delete generated lobbies/games and fake users (prefix 'fake_user_') + """ + + help = "Generate sample Durak games with move history for UI & statistics testing." + + def add_arguments(self, parser): + """Add command-line arguments. + + Args: + parser: Argument parser provided by Django. + """ + parser.add_argument("--games", type=int, default=5, help="Number of games to generate (default: 5)") + parser.add_argument("--players", type=int, default=4, help="Players per game (2-8 recommended, default: 4)") + parser.add_argument("--moves", type=int, default=20, help="Approx number of moves per game (default: 20)") + parser.add_argument( + "--card-count", + type=int, + choices=[24, 36, 52], + default=52, + help="Deck size per lobby (24, 36, or 52). Default: 52", + ) + parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducible runs") + parser.add_argument( + "--reset", + action="store_true", + default=False, + help="Delete generated lobbies/games and fake users (prefix 'fake_user_')", + ) + + def handle(self, *args, **options): + """Main entry point. + + Args: + *args: Positional args (unused). + **options: Parsed CLI options. + """ + games = options["games"] + players = options["players"] + approx_moves = options["moves"] + card_count = options["card_count"] + seed = options["seed"] + do_reset = options["reset"] + + if seed is not None: + random.seed(seed) + + if do_reset: + self._reset_generated_lobbies_and_users() + return + + if not (2 <= players <= 8): + self.stdout.write(self.style.WARNING("players outside normal range (2-8). Continuing anyway.")) + + self.stdout.write( + f"Generating {games} game(s), {players} players each, ~{approx_moves} moves per game, {card_count}-card decks..." + ) + + with transaction.atomic(): + self._ensure_suits_and_ranks(card_count) + self._ensure_cards(card_count) + + # estimate users needed: owner + players per game, reuse users when possible + users = self._ensure_users(max_needed=games * (players + 1)) + + # cycle through the users so we can reuse them across games if fewer provided + user_iter = itertools.cycle(users) + created_games = [] + + for g_idx in range(games): + game = self._create_fake_game( + players=players, + approx_moves=approx_moves, + card_count=card_count, + user_iter=user_iter, + game_index=g_idx + 1, + ) + created_games.append(str(game.id)) + self.stdout.write(self.style.SUCCESS(f"Created Game {game.id} in Lobby '{game.lobby.name}'")) + + self.stdout.write(self.style.SUCCESS(f"Done. Created {len(created_games)} games: {created_games}")) + + # --------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------- + def _reset_generated_lobbies_and_users(self): + """Remove lobbies that contain any player who belongs to the Test_Users group, + then remove non-staff/non-superuser users who are members of that group. + + Behavior: + - Find the Group named 'Test_Users'. If it doesn't exist, nothing to delete. + - Find all users in that group. + - Find all Lobby objects that have a LobbyPlayer referencing any of those users, + and delete those lobbies (and their related game rows). + - Delete users in the group, excluding staff and superusers if those flags exist. + """ + with transaction.atomic(): + # Resolve the marker group + try: + marker_group = Group.objects.get(name="Test_Users") + except Group.DoesNotExist: + self.stdout.write(self.style.NOTICE("Group 'Test_Users' does not exist. Nothing to reset.")) + return + + # All users who are members of the marker group + users_in_group = User.objects.filter(groups=marker_group) + + total_users = users_in_group.count() + if total_users == 0: + self.stdout.write(self.style.NOTICE("No users found in group 'Test_Users'. Nothing to reset.")) + return + + # Find lobby IDs that have at least one LobbyPlayer who is in that group + lobby_ids_qs = LobbyPlayer.objects.filter(user__in=users_in_group).values_list("lobby_id", + flat=True).distinct() + lobby_ids = list(lobby_ids_qs) + + if lobby_ids: + lobby_qs = Lobby.objects.filter(id__in=lobby_ids) + deleted_count, details = lobby_qs.delete() + self.stdout.write(self.style.WARNING( + f"Deleted {deleted_count} objects belonging to {len(lobby_ids)} lobby(ies) that had Test_Users members (details: {details})." + )) + else: + self.stdout.write(self.style.NOTICE("No lobbies found that contain users from group 'Test_Users'.")) + + # Now delete non-staff, non-superuser users who belong to the marker group + users_to_delete = users_in_group + if hasattr(User, "is_staff"): + users_to_delete = users_to_delete.exclude(is_staff=True) + if hasattr(User, "is_superuser"): + users_to_delete = users_to_delete.exclude(is_superuser=True) + + count_to_delete = users_to_delete.count() + if count_to_delete: + u_deleted_count, u_deleted_details = users_to_delete.delete() + self.stdout.write(self.style.WARNING( + f"Deleted {count_to_delete} user(s) from group 'Test_Users' (deleted objects: {u_deleted_count})." + )) + else: + self.stdout.write( + self.style.NOTICE("No non-staff/non-superuser users in group 'Test_Users' to delete.")) + + def _ensure_suits_and_ranks(self, card_count: int): + """Ensure CardSuit and CardRank rows exist. + + Args: + card_count: Number of cards in deck (24/36/52) to decide which ranks to create. + """ + suits = {"Hearts": "red", "Diamonds": "red", "Clubs": "black", "Spades": "black"} + for name, color in suits.items(): + CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + + if card_count == 52: + ranks = DEFAULT_RANKS_52 + elif card_count == 36: + ranks = DEFAULT_RANKS_36 + else: + ranks = DEFAULT_RANKS_24 + + existing_values = set(CardRank.objects.values_list("value", flat=True)) + for name, val in ranks: + if val not in existing_values: + CardRank.objects.create(name=name, value=val) + + def _ensure_cards(self, card_count: int): + """Ensure Card objects exist for the requested deck size. + + Args: + card_count: Deck size (24/36/52). + """ + suits = list(CardSuit.objects.all()) + if card_count == 52: + ranks_qs = CardRank.objects.all() + elif card_count == 36: + ranks_qs = CardRank.objects.filter(value__gte=6) + else: + ranks_qs = CardRank.objects.filter(value__gte=9) + + ranks = list(ranks_qs) + created = 0 + for suit in suits: + for rank in ranks: + _, created_flag = Card.objects.get_or_create(suit=suit, rank=rank) + if created_flag: + created += 1 + if created: + self.stdout.write(self.style.NOTICE(f"Created {created} Card objects.")) + + def _ensure_users(self, max_needed: int) -> List[User]: + """Ensure at least `max_needed` users exist. + + The method prefers to call the `generate_test_users` management command + (from the accounts app). It requests creation with prefix `fake_user_` and + marker group `Test_Users`. If the call fails, it will fall back to inline + creation to ensure generation continues. + + Args: + max_needed: Minimum number of users required. + + Returns: + List of User instances (length >= max_needed). + """ + existing = list(User.objects.all().order_by("id")) + if len(existing) >= max_needed: + return existing[:max_needed] + + needed = max_needed - len(existing) + + prefix = "fake_user_" + existing_fake = list(User.objects.filter(username__startswith=prefix)) + max_index = 0 + for u in existing_fake: + m = re.search(rf'^{re.escape(prefix)}(\d+)$', u.username) + if m: + try: + idx = int(m.group(1)) + if idx > max_index: + max_index = idx + except Exception: + pass + start = max_index + 1 + + try: + # Ask the accounts app's generate_test_users command to create the missing users + call_command( + "generate_test_users", + "--count", + str(needed), + "--prefix", + prefix, + "--start", + str(start), + "--marker-group", + "Test_Users", + ) + self.stdout.write(self.style.NOTICE( + f"Requested {needed} test user(s) via generate_test_users (prefix={prefix}, start={start}).")) + except Exception as exc: + # Fallback inline creation + self.stdout.write( + self.style.WARNING(f"Calling generate_test_users failed: {exc}. Falling back to inline creation.")) + created_users = [] + for i in range(needed): + username = f"{prefix}{start + i}" + try: + user = User.objects.create_user(username=username, password="testpass") + except Exception: + #Try create without password method if custom user model differs + user = User.objects.create(username=username) + created_users.append(user) + self.stdout.write(self.style.NOTICE(f"Fallback created {len(created_users)} users.")) + all_users = existing + created_users + return all_users[:max_needed] + + all_users = list(User.objects.all().order_by("id")) + return all_users[:max_needed] + + def _draw_from_deck_list(self, deck_cards: List[Card]) -> Optional[Card]: + """Pop a card from a list representing the deck. + + Args: + deck_cards: Mutable list acting as the deck (front = top). + + Returns: + Card or None if deck is empty. + """ + if not deck_cards: + return None + return deck_cards.pop(0) + + def _create_fake_game(self, players: int, approx_moves: int, card_count: int, user_iter, game_index: int) -> Game: + """Create a single fake lobby/game and simulate play. + + Args: + players: Number of players in the created game. + approx_moves: Approximate number of attack/defend/pickup moves to simulate. + card_count: Deck size (24/36/52). + user_iter: Iterator that yields User objects (owner + players). + game_index: One-based index used for naming. + + Returns: + The created Game instance. + """ + owner_user = next(user_iter) + # NOTE: lobby name no longer uses 'Fake Lobby' prefix to avoid relying on names for cleanup. + lobby = Lobby.objects.create( + owner=owner_user, + name=f"Generated Lobby {uuid.uuid4().hex[:6]}", + is_private=False, + status="playing", + ) + + LobbySettings.objects.create( + lobby=lobby, + max_players=players, + card_count=card_count, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False, + ) + + # Build list of Card objects representing the deck + if card_count == 52: + ranks_qs = CardRank.objects.all() + elif card_count == 36: + ranks_qs = CardRank.objects.filter(value__gte=6) + else: + ranks_qs = CardRank.objects.filter(value__gte=9) + + ranks = list(ranks_qs) + suits = list(CardSuit.objects.all()) + deck_cards: List[Card] = [] + for suit in suits: + for rank in ranks: + try: + deck_cards.append(Card.objects.get(suit=suit, rank=rank)) + except Card.DoesNotExist: + continue + + random.shuffle(deck_cards) + trump_card_obj = deck_cards[-1] if deck_cards else None + if trump_card_obj is None: + raise RuntimeError("No Card objects available to create a game.") + + game = Game.objects.create(lobby=lobby, trump_card=trump_card_obj, status="in_progress") + + # Persist deck into GameDeck model + for pos, card in enumerate(deck_cards, start=1): + GameDeck.objects.create(game=game, card=card, position=pos) + + # Create LobbyPlayer and GamePlayer entries + game_players: List[GamePlayer] = [] + for seat in range(1, players + 1): + user = next(user_iter) + LobbyPlayer.objects.create(lobby=lobby, user=user, status="playing") + gp = GamePlayer.objects.create(game=game, user=user, seat_position=seat, cards_remaining=0) + game_players.append(gp) + + # Helper to pop top GameDeck entry (DB-backed) + def pop_top_db_card(game_obj: Game) -> Optional[Card]: + top_qs = GameDeck.objects.filter(game=game_obj).order_by("position")[:1] + if not top_qs: + return None + gd = top_qs[0] + card = gd.card + gd.delete() + return card + + # Deal initial hands (6 cards typical) + initial_hand_size = 6 + for gp in game_players: + for idx in range(initial_hand_size): + card = pop_top_db_card(game) + if not card: + break + PlayerHand.objects.create(game=game, player=gp.user, card=card, order_in_hand=idx + 1) + gp.cards_remaining += 1 + gp.save(update_fields=["cards_remaining"]) + + # Simulate a sequence of turns and moves + current_attacker_index = 0 + turn_counter = 0 + discard_position = 1 + + def find_defense_card_for_player(defender_user, attack_card): + """Return a PlayerHand instance that can defend or None.""" + ph_qs = PlayerHand.objects.filter(game=game, player=defender_user) + for ph in ph_qs: + try: + if ph.card.can_beat(attack_card, game.get_trump_suit()): + return ph + except Exception: + return None + return None + + for _ in range(approx_moves): + turn_counter += 1 + attacker_gp = game_players[current_attacker_index % len(game_players)] + attacker_user = attacker_gp.user + turn = Turn.objects.create(game=game, player=attacker_user, turn_number=turn_counter) + + attacker_hand = list(PlayerHand.objects.filter(game=game, player=attacker_user).order_by("order_in_hand")) + if not attacker_hand: + current_attacker_index += 1 + continue + + attack_ph = attacker_hand[0] + attack_card = attack_ph.card + attack_ph.delete() + attacker_gp.cards_remaining = max(0, attacker_gp.cards_remaining - 1) + attacker_gp.save(update_fields=["cards_remaining"]) + + table_card = TableCard.objects.create(game=game, attack_card=attack_card) + Move.objects.create(turn=turn, table_card=table_card, action_type="attack", created_at=timezone.now()) + + defender_index = (current_attacker_index + 1) % len(game_players) + defender_gp = game_players[defender_index] + defender_user = defender_gp.user + + defense_ph = find_defense_card_for_player(defender_user, attack_card) + if defense_ph: + defense_card = defense_ph.card + table_card.defense_card = defense_card + table_card.save(update_fields=["defense_card"]) + defense_ph.delete() + defender_gp.cards_remaining = max(0, defender_gp.cards_remaining - 1) + defender_gp.save(update_fields=["cards_remaining"]) + + Move.objects.create(turn=turn, table_card=table_card, action_type="defend", created_at=timezone.now()) + + DiscardPile.objects.create(game=game, card=attack_card, position=discard_position) + discard_position += 1 + DiscardPile.objects.create(game=game, card=defense_card, position=discard_position) + discard_position += 1 + else: + Move.objects.create(turn=turn, table_card=table_card, action_type="pickup", created_at=timezone.now()) + PlayerHand.objects.create(game=game, player=defender_user, card=attack_card, + order_in_hand=defender_gp.cards_remaining + 1) + defender_gp.cards_remaining += 1 + defender_gp.save(update_fields=["cards_remaining"]) + + # Refill hands up to 6 cards + for gp in game_players: + while gp.cards_remaining < 6: + card = pop_top_db_card(game) + if not card: + break + PlayerHand.objects.create(game=game, player=gp.user, card=card, + order_in_hand=gp.cards_remaining + 1) + gp.cards_remaining += 1 + gp.save(update_fields=["cards_remaining"]) + + current_attacker_index = (current_attacker_index + 1) % len(game_players) + + # Optionally finish the game and pick a loser + if random.random() < 0.6: + loser_gp = random.choice(game_players) + game.loser = loser_gp.user + game.status = "finished" + game.finished_at = timezone.now() + game.save(update_fields=["loser", "status", "finished_at"]) + + return game diff --git a/game/management/commands/import_db.py b/game/management/commands/import_db.py new file mode 100644 index 0000000..d36622c --- /dev/null +++ b/game/management/commands/import_db.py @@ -0,0 +1,302 @@ +""" +Import JSON (Django serialization) into the database with optional filtering. + +This management command imports JSON previously exported by `export_db` (Django +serialized objects). It supports gzipped input, an app-label filter (--apps), +an optional full DB flush (--clear), and resetting database sequences for +PostgreSQL-driven backends. + +Behavior summary +--------------- +- Input: path to JSON or '-' for stdin. '.gz' files are supported. +- --apps: comma-separated list of app labels to import (if omitted, import all). +- --ignore-errors: attempt to continue past individual object save errors. +- --clear: run `manage.py flush --noinput` before importing (DANGEROUS). +- Sequence reset: automatically attempted for Postgres or when --reset-sequences is given. +""" + +import os +import gzip +from typing import Optional, Set, List, Iterable, Tuple + +from django.core.management.base import BaseCommand, CommandError +from django.core import serializers +from django.db import transaction, IntegrityError, connection +from django.core.management import call_command +from django.conf import settings +from django.apps import apps +from django.core.management.color import no_style + + +class Command(BaseCommand): + """ + Import JSON exported by export_db (Django serialization) with optional app filter. + + The command accepts a Django-serialized JSON array (optionally gzipped) and + deserializes objects into the database. Use `--apps` to restrict import to + objects belonging to a set of app labels (comma-separated). When PostgreSQL + is detected (or `--reset-sequences` is passed) the command will attempt to + reset DB sequences — by default for all models, or only for the selected apps + when `--apps` is used. + """ + + help = "Import JSON exported by export_db (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "input", + help="Input JSON file path (relative paths searched in BASE_DIR). Use '-' for stdin. Supports .gz.", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Clear the database via `flush --noinput` before importing. USE WITH CARE.", + ) + parser.add_argument( + "--ignore-errors", + action="store_true", + help="Try to continue past individual object errors (logs them).", + ) + parser.add_argument( + "--reset-sequences", + action="store_true", + help="Attempt to reset DB sequences after import (Postgres only). Default: on for Postgres.", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to import (e.g. 'auth,game'). If omitted imports all apps.", + default=None, + ) + + # ------------------------- + # Helper utilities + # ------------------------- + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """ + Parse the --apps argument into a set of app labels. + + Returns a set of app labels, or None if apps_arg is falsy. + """ + if not apps_arg: + return None + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def _resolve_input_path(self, path: str) -> str: + """ + Resolve a possibly-relative input path against BASE_DIR. + + If path is absolute, return as-is. If relative, join with BASE_DIR (or cwd). + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + return path + + def _read_raw_input(self, input_path: str, from_stdin: bool) -> str: + """ + Read input JSON text from a file (supports .gz) or from stdin. + + Raises CommandError if file missing or unreadable. + """ + if from_stdin: + return self.stdin.read() + + if not os.path.exists(input_path): + raise CommandError(f"Input file not found: {input_path}") + + try: + if input_path.endswith(".gz"): + with gzip.open(input_path, "rt", encoding="utf-8") as fh: + return fh.read() + else: + with open(input_path, "r", encoding="utf-8") as fh: + return fh.read() + except OSError as e: + raise CommandError(f"Failed reading input file '{input_path}': {e}") + + def _deserialized_iter(self, raw: str) -> Iterable: + """ + Return an iterator of deserialized objects from a JSON string. + + Raises CommandError if deserialization fails. + """ + try: + return serializers.deserialize("json", raw) + except Exception as e: + raise CommandError(f"Failed to deserialize input JSON: {e}") + + def _maybe_flush(self): + """Perform database flush (via manage.py flush) if requested.""" + try: + call_command("flush", "--noinput") + except Exception as e: + raise CommandError(f"Failed to flush database: {e}") + + # ------------------------- + # Import core + # ------------------------- + def _import_objects( + self, + deserialized_iter: Iterable, + apps_filter: Optional[Set[str]], + ignore_errors: bool + ) -> Tuple[int, int, List[str]]: + """ + Iterate the deserialized objects and save them to the DB. + + Returns (saved_count, skipped_count, errors_list). + + Behavior: + - If apps_filter is provided, objects whose model's app_label are not in + the set will be skipped (counted in skipped_count). + - If ignore_errors is True, exceptions for individual objects are logged + and the loop continues; objects are saved in per-object transactions + to avoid leaving partial state locked in a big transaction. + - If ignore_errors is False, the entire import runs in one transaction + and any error aborts the import (exception propagates). + """ + saved = 0 + skipped = 0 + errors: List[str] = [] + + if ignore_errors: + # Save each object in its own small transaction so we can continue on error. + for des_obj in deserialized_iter: + try: + obj_app_label = des_obj.object._meta.app_label + except Exception: + skipped += 1 + continue + + if apps_filter is not None and obj_app_label not in apps_filter: + skipped += 1 + continue + + try: + with transaction.atomic(): + des_obj.save() + saved += 1 + except IntegrityError as e: + msg = f"IntegrityError saving {des_obj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + # continue to next object + except Exception as e: + msg = f"Error saving {des_obj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + # continue to next object + else: + # Perform the import inside a single transaction — consistent commit or rollback. + try: + with transaction.atomic(): + for des_obj in deserialized_iter: + try: + obj_app_label = des_obj.object._meta.app_label + except Exception: + skipped += 1 + continue + + if apps_filter is not None and obj_app_label not in apps_filter: + skipped += 1 + continue + + des_obj.save() + saved += 1 + except IntegrityError: + # Let the IntegrityError propagate after reporting (so caller sees it), + # the transaction will rollback automatically. + raise + except Exception: + # Any other exception also should propagate out and rollback. + raise + + return saved, skipped, errors + + # ------------------------- + # Sequence reset + # ------------------------- + def _reset_postgres_sequences(self, apps_filter: Optional[Set[str]]) -> Optional[str]: + """ + Reset Postgres sequences for models (all models or filtered by apps_filter). + + Returns None on success, or an error message string on failure. + """ + style = no_style() + try: + if apps_filter is None: + models_to_reset = list(apps.get_models()) + else: + models_to_reset = [m for m in apps.get_models() if m._meta.app_label in apps_filter] + + sql_list = connection.ops.sequence_reset_sql(style, models_to_reset) + with connection.cursor() as cursor: + for sql in sql_list: + if sql.strip(): + cursor.execute(sql) + return None + except Exception as e: + return f"Failed to reset sequences: {e}" + + # ------------------------- + # Main handle + # ------------------------- + def handle(self, *args, **options): + """ + Main command entry. + + Steps: + 1. Resolve input (path/stdin) and read raw JSON text. + 2. Optionally flush DB (--clear). + 3. Deserialize objects and import them (respecting --apps and --ignore-errors). + 4. Optionally reset Postgres sequences (automatic on Postgres or via --reset-sequences). + 5. Print a summary and raise CommandError on fatal problems. + """ + input_path = options["input"] + do_clear = options["clear"] + ignore_errors = options["ignore_errors"] + reset_sequences_flag = options["reset_sequences"] + apps_arg = options["apps"] + + apps_filter = self._parse_apps_arg(apps_arg) + + read_from_stdin = (input_path == "-") + if not read_from_stdin: + input_path = self._resolve_input_path(input_path) + + # Optional DB flush + if do_clear: + self.stdout.write(self.style.WARNING("Flushing the database (this will remove ALL data)...")) + self._maybe_flush() + + # Read raw input + raw = self._read_raw_input(input_path, read_from_stdin) if not read_from_stdin else self._read_raw_input(input_path, True) + if not raw or not raw.strip(): + raise CommandError("Input file is empty.") + + # Prepare deserialized iterator + deserialized_iter = self._deserialized_iter(raw) + + # Import objects + saved, skipped, errors = self._import_objects(deserialized_iter, apps_filter, ignore_errors) + + # Decide whether to reset sequences: + engine = connection.settings_dict.get("ENGINE", "") + is_postgres = "postgresql" in engine or connection.vendor == "postgresql" + do_reset = reset_sequences_flag or is_postgres + + if do_reset and is_postgres: + reset_err = self._reset_postgres_sequences(apps_filter) + if reset_err: + self.stderr.write(self.style.ERROR(reset_err)) + errors.append(reset_err) + else: + self.stdout.write(self.style.SUCCESS("Postgres sequences reset successfully.")) + + # Summary + self.stdout.write(self.style.SUCCESS(f"Imported {saved} objects.")) + if skipped: + self.stdout.write(self.style.WARNING(f"Skipped {skipped} objects (outside --apps or invalid).")) + if errors: + self.stdout.write(self.style.WARNING(f"{len(errors)} errors occurred during import. See stderr for details.")) diff --git a/game/management/commands/init_game_data.py b/game/management/commands/init_game_data.py new file mode 100644 index 0000000..d113a4b --- /dev/null +++ b/game/management/commands/init_game_data.py @@ -0,0 +1,190 @@ +""" +Initialize default card suits, ranks and create Card entries. + +This management command will create standard card suits and ranks and then +create Card objects for each suit × rank combination for a chosen deck size. + +Usage: + python manage.py init_game_data + python manage.py init_game_data --deck-size 36 + python manage.py init_game_data --reset + +The command is idempotent by default (it uses get_or_create and updates mismatched +names/colors). Using --reset will delete existing Card, CardRank and CardSuit +records before recreating them. + +Module contents: + Command -- Django management command class implementing the behavior. +""" + +from typing import List, Tuple + +from django.core.management.base import BaseCommand +from django.db import transaction + +from game.models import CardSuit, CardRank, Card + + +class Command(BaseCommand): + """ + Django management command to initialize card suits, ranks and cards. + + The command supports 24-, 36- and 52-card decks and an optional reset flag + which deletes existing Card, CardRank and CardSuit records before creating + new ones. + + Attributes: + help (str): Short description displayed by `manage.py help`. + """ + + # Standard four suits with their display colors. + suits = [ + ("Hearts", "red"), + ("Diamonds", "red"), + ("Clubs", "black"), + ("Spades", "black"), + ] + + help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)." + + def add_arguments(self, parser): + """ + Add command-line arguments for the management command. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + Recognized flags: + --deck-size {24,36,52}: Which deck to create (default 52). + --reset: If present, deletes existing Card/Rank/Suit rows before creating. + """ + parser.add_argument( + "--deck-size", + type=int, + choices=[24, 36, 52], + default=52, + help="Which deck to create cards for: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.", + ) + + def ranks_for_deck(self, size: int) -> List[Tuple[str, int]]: + """ + Return a list of (name, value) tuples representing card ranks for the given deck size. + + The returned list orders ranks from lowest to highest numeric value. + + Args: + size (int): Deck size. Supported values: 24, 36, 52. + + Returns: + List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. + + Raises: + ValueError: If an unsupported deck size is supplied. + """ + face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] + if size == 52: + # 2..10 plus face cards + numeric = [(str(i), i) for i in range(2, 11)] + return numeric + face + if size == 36: + # 6..10 plus face cards (typical Durak deck) + numeric = [(str(i), i) for i in range(6, 11)] + return numeric + face + if size == 24: + # 9..10 plus face cards (short deck) + numeric = [("9", 9), ("10", 10)] + return numeric + face + raise ValueError("Unsupported deck size") + + def create_suits(self): + # Create or update suits + created_suits = [] + for name, color in self.suits: + suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + # If suit exists but has a different color, update it to our canonical color. + if not created and getattr(suit_obj, "color", None) != color: + suit_obj.color = color + suit_obj.save(update_fields=["color"]) + created_suits.append(suit_obj) + self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") + return created_suits + + def create_ranks(self, ranks: List[Tuple[str, int]]) -> List[Tuple[str, int]]: + # Create or update ranks + created_ranks = [] + for name, value in ranks: + rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) + # Normalize the printable name if it differs from our desired name. + if not created and getattr(rank_obj, "name", None) != name: + rank_obj.name = name + rank_obj.save(update_fields=["name"]) + created_ranks.append(rank_obj) + self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") + return created_ranks + + def create_cards(self, ranks: List[Tuple[str, int]]): + # Create cards for every suit × rank (skip cards that already exist) + created_cards = 0 + skipped_cards = 0 + created_suits = self.create_suits() + created_ranks = self.create_ranks(ranks) + for suit in created_suits: + for rank in created_ranks: + # If a Card with the suit & rank already exists (and is not a special card), + # skip creating a duplicate. + card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) + if card_qs.exists(): + skipped_cards += 1 + continue + Card.objects.create(suit=suit, rank=rank) + created_cards += 1 + + return created_cards, skipped_cards + + def handle(self, *args, **options): + """ + Main entry point for the management command. + + This method creates suits and ranks (using get_or_create so it is safe to + run repeatedly), then creates Card objects for each combination of suit + and rank. If --reset is passed, existing Card, CardRank and CardSuit + records will be deleted first. + + Args: + *args: Positional arguments (unused). + **options: Command options dictionary with keys: + deck_size (int): Deck size to create (24, 36, 52). + reset (bool): Whether to delete existing entries first. + + Raises: + ValueError: If an unsupported deck size is provided (shouldn't happen + because argparse restricts choices). + """ + deck_size = options["deck_size"] + do_reset = options["reset"] + + ranks = self.ranks_for_deck(deck_size) + + with transaction.atomic(): + if do_reset: + self.stdout.write("Reset requested — deleting existing Cards, CardRank and CardSuit entries...") + # Delete Cards first because of foreign key references to ranks & suits + Card.objects.all().delete() + CardRank.objects.all().delete() + CardSuit.objects.all().delete() + self.stdout.write("Existing card data deleted.") + + created_cards, skipped_cards = self.create_cards(ranks) + + # Summary output + self.stdout.write(self.style.SUCCESS( + f"Deck initialization finished for deck_size={deck_size}." + )) + self.stdout.write(f"Cards created: {created_cards}. Cards already present: {skipped_cards}.") + self.stdout.write( + f"Total suits: {CardSuit.objects.count()}; ranks: {CardRank.objects.count()}; cards: {Card.objects.count()}") diff --git a/game/management/commands/reset_games.py b/game/management/commands/reset_games.py new file mode 100644 index 0000000..36d09d7 --- /dev/null +++ b/game/management/commands/reset_games.py @@ -0,0 +1,179 @@ +""" +Initialize default card suits, ranks and create Card entries. + +This management command will create standard card suits and ranks and then +create Card objects for each suit × rank combination for a chosen deck size. + +Usage: + python manage.py init_game_data + python manage.py init_game_data --deck-size 36 + python manage.py init_game_data --reset + +The command is idempotent by default (it uses get_or_create and updates mismatched +names/colors). Using --reset will delete existing Card, CardRank and CardSuit +records before recreating them. + +Module contents: + Command -- Django management command class implementing the behavior. +""" + +from typing import List, Tuple + +from django.core.management.base import BaseCommand +from django.db import transaction + +from game.models import CardSuit, CardRank, Card + + +class Command(BaseCommand): + """ + Django management command to initialize card suits, ranks and cards. + + The command supports 24-, 36- and 52-card decks and an optional reset flag + which deletes existing Card, CardRank and CardSuit records before creating + new ones. + + Attributes: + help (str): Short description displayed by `manage.py help`. + """ + + help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)." + + def add_arguments(self, parser): + """ + Add command-line arguments for the management command. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + Recognized flags: + --deck-size {24,36,52}: Which deck to create (default 52). + --reset: If present, deletes existing Card/Rank/Suit rows before creating. + """ + parser.add_argument( + "--deck-size", + type=int, + choices=[24, 36, 52], + default=52, + help="Which deck to create cards for: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.", + ) + + def handle(self, *args, **options): + """ + Main entry point for the management command. + + This method creates suits and ranks (using get_or_create so it is safe to + run repeatedly), then creates Card objects for each combination of suit + and rank. If --reset is passed, existing Card, CardRank and CardSuit + records will be deleted first. + + Args: + *args: Positional arguments (unused). + **options: Command options dictionary with keys: + deck_size (int): Deck size to create (24, 36, 52). + reset (bool): Whether to delete existing entries first. + + Raises: + ValueError: If an unsupported deck size is provided (shouldn't happen + because argparse restricts choices). + """ + deck_size = options["deck_size"] + do_reset = options["reset"] + + # Standard four suits with their display colors. + suits = [ + ("Hearts", "red"), + ("Diamonds", "red"), + ("Clubs", "black"), + ("Spades", "black"), + ] + + def ranks_for_deck(size: int) -> List[Tuple[str, int]]: + """ + Return a list of (name, value) tuples representing card ranks for the given deck size. + + The returned list orders ranks from lowest to highest numeric value. + + Args: + size (int): Deck size. Supported values: 24, 36, 52. + + Returns: + List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. + + Raises: + ValueError: If an unsupported deck size is supplied. + """ + face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] + if size == 52: + # 2..10 plus face cards + numeric = [(str(i), i) for i in range(2, 11)] + return numeric + face + if size == 36: + # 6..10 plus face cards (typical Durak deck) + numeric = [(str(i), i) for i in range(6, 11)] + return numeric + face + if size == 24: + # 9..10 plus face cards (short deck) + numeric = [("9", 9), ("10", 10)] + return numeric + face + raise ValueError("Unsupported deck size") + + ranks = ranks_for_deck(deck_size) + + with transaction.atomic(): + if do_reset: + self.stdout.write("Reset requested — deleting existing Cards, CardRank and CardSuit entries...") + # Delete Cards first because of foreign key references to ranks & suits + Card.objects.all().delete() + CardRank.objects.all().delete() + CardSuit.objects.all().delete() + self.stdout.write("Existing card data deleted.") + + # Create or update suits + created_suits = [] + for name, color in suits: + suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + # If suit exists but has a different color, update it to our canonical color. + if not created and getattr(suit_obj, "color", None) != color: + suit_obj.color = color + suit_obj.save(update_fields=["color"]) + created_suits.append(suit_obj) + self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") + + # Create or update ranks + created_ranks = [] + for name, value in ranks: + rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) + # Normalize the printable name if it differs from our desired name. + if not created and getattr(rank_obj, "name", None) != name: + rank_obj.name = name + rank_obj.save(update_fields=["name"]) + created_ranks.append(rank_obj) + self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") + + # Create cards for every suit × rank (skip cards that already exist) + created_cards = 0 + skipped_cards = 0 + for suit in created_suits: + for rank in created_ranks: + # If a Card with the suit & rank already exists (and is not a special card), + # skip creating a duplicate. + card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) + if card_qs.exists(): + skipped_cards += 1 + continue + Card.objects.create(suit=suit, rank=rank) + created_cards += 1 + + # Summary output + self.stdout.write(self.style.SUCCESS( + f"Deck initialization finished for deck_size={deck_size}." + )) + self.stdout.write(f"Cards created: {created_cards}. Cards already present: {skipped_cards}.") + self.stdout.write( + f"Total suits: {CardSuit.objects.count()}; ranks: {CardRank.objects.count()}; cards: {Card.objects.count()}") diff --git a/game/migrations/0001_initial.py b/game/migrations/0001_initial.py new file mode 100644 index 0000000..929e5dd --- /dev/null +++ b/game/migrations/0001_initial.py @@ -0,0 +1,187 @@ +# Generated by Django 5.2.6 on 2025-10-06 19:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CardRank', + fields=[ + ('id', models.SmallAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=20)), + ('value', models.IntegerField()), + ], + ), + migrations.CreateModel( + name='CardSuit', + fields=[ + ('id', models.SmallAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=20)), + ('color', models.CharField(choices=[('red', 'Red'), ('black', 'Black')], max_length=5)), + ], + ), + migrations.CreateModel( + name='SpecialCard', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50)), + ('effect_type', models.CharField(choices=[('skip', 'Skip'), ('reverse', 'Reverse'), ('draw', 'Draw'), ('custom', 'Custom')], max_length=10)), + ('effect_value', models.JSONField(blank=True, null=True)), + ('description', models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='SpecialRuleSet', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50)), + ('description', models.TextField(blank=True, null=True)), + ('min_players', models.PositiveIntegerField()), + ], + ), + migrations.CreateModel( + name='Card', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rank', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.cardrank')), + ('suit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.cardsuit')), + ('special_card', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='game.specialcard')), + ], + ), + migrations.CreateModel( + name='Game', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('in_progress', 'In Progress'), ('finished', 'Finished')], max_length=15)), + ('loser', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('trump_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='as_trump', to='game.card')), + ], + ), + migrations.CreateModel( + name='DiscardPile', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('position', models.IntegerField(blank=True, null=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ], + ), + migrations.CreateModel( + name='GameDeck', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('position', models.IntegerField()), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ], + ), + migrations.CreateModel( + name='GamePlayer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('seat_position', models.IntegerField()), + ('cards_remaining', models.IntegerField()), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to='game.game')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Lobby', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('is_private', models.BooleanField(default=False)), + ('password_hash', models.CharField(blank=True, max_length=128, null=True)), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('playing', 'Playing'), ('closed', 'Closed')], max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='game', + name='lobby', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.lobby'), + ), + migrations.CreateModel( + name='LobbyPlayer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('ready', 'Ready'), ('playing', 'Playing'), ('left', 'Left')], max_length=10)), + ('lobby', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to='game.lobby')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='PlayerHand', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('order_in_hand', models.IntegerField(blank=True, null=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LobbySettings', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('max_players', models.PositiveIntegerField()), + ('card_count', models.IntegerField(choices=[(24, '24'), (36, '36'), (52, '52')])), + ('is_transferable', models.BooleanField(default=False)), + ('neighbor_throw_only', models.BooleanField(default=False)), + ('allow_jokers', models.BooleanField(default=False)), + ('turn_time_limit', models.IntegerField(blank=True, null=True)), + ('lobby', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='game.lobby')), + ('special_rule_set', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='game.specialruleset')), + ], + ), + migrations.CreateModel( + name='SpecialRuleSetCard', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.specialcard')), + ('rule_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.specialruleset')), + ], + ), + migrations.CreateModel( + name='TableCard', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('attack_card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attack_card', to='game.card')), + ('defense_card', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='defense_card', to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ], + ), + migrations.CreateModel( + name='Turn', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('turn_number', models.IntegerField()), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Move', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action_type', models.CharField(choices=[('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup')], max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('table_card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.tablecard')), + ('turn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.turn')), + ], + ), + ] diff --git a/game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py b/game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py new file mode 100644 index 0000000..098c561 --- /dev/null +++ b/game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py @@ -0,0 +1,139 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:08 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('game', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='turn', + name='game', + ), + migrations.RemoveField( + model_name='turn', + name='player', + ), + migrations.AlterModelOptions( + name='card', + options={'verbose_name': 'Card', 'verbose_name_plural': 'Cards'}, + ), + migrations.AlterModelOptions( + name='cardrank', + options={'ordering': ['value'], 'verbose_name': 'Card Rank', 'verbose_name_plural': 'Card Ranks'}, + ), + migrations.AlterModelOptions( + name='cardsuit', + options={'ordering': ['name'], 'verbose_name': 'Card Suit', 'verbose_name_plural': 'Card Suits'}, + ), + migrations.AlterModelOptions( + name='game', + options={'ordering': ['-started_at'], 'verbose_name': 'Game', 'verbose_name_plural': 'Games'}, + ), + migrations.AlterModelOptions( + name='gamedeck', + options={'ordering': ['position'], 'verbose_name': 'Game Deck Card', 'verbose_name_plural': 'Game Deck Cards'}, + ), + migrations.AlterModelOptions( + name='gameplayer', + options={'ordering': ['seat_position'], 'verbose_name': 'Game Player', 'verbose_name_plural': 'Game Players'}, + ), + migrations.AlterModelOptions( + name='lobby', + options={'ordering': ['-created_at'], 'verbose_name': 'Lobby', 'verbose_name_plural': 'Lobbies'}, + ), + migrations.AlterModelOptions( + name='lobbyplayer', + options={'ordering': ['lobby', 'user__username'], 'verbose_name': 'Lobby Player', 'verbose_name_plural': 'Lobby Players'}, + ), + migrations.AlterModelOptions( + name='lobbysettings', + options={'verbose_name': 'Lobby Settings', 'verbose_name_plural': 'Lobby Settings'}, + ), + migrations.AlterModelOptions( + name='playerhand', + options={'ordering': ['order_in_hand'], 'verbose_name': 'Player Hand Card', 'verbose_name_plural': 'Player Hand Cards'}, + ), + migrations.AlterModelOptions( + name='specialcard', + options={'ordering': ['name'], 'verbose_name': 'Special Card', 'verbose_name_plural': 'Special Cards'}, + ), + migrations.AlterModelOptions( + name='specialruleset', + options={'ordering': ['name'], 'verbose_name': 'Special Rule Set', 'verbose_name_plural': 'Special Rule Sets'}, + ), + migrations.AlterModelOptions( + name='specialrulesetcard', + options={'ordering': ['rule_set__name', 'card__name'], 'verbose_name': 'Special Rule Set Card', 'verbose_name_plural': 'Special Rule Set Cards'}, + ), + migrations.AlterModelOptions( + name='tablecard', + options={'ordering': ['id'], 'verbose_name': 'Table Card', 'verbose_name_plural': 'Table Cards'}, + ), + migrations.AddField( + model_name='specialrulesetcard', + name='is_enabled', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='specialcard', + name='effect_type', + field=models.CharField(choices=[('skip', 'Skip Turn'), ('reverse', 'Reverse Order'), ('draw', 'Draw Cards'), ('custom', 'Custom Effect')], max_length=20), + ), + migrations.AlterField( + model_name='specialcard', + name='effect_value', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='specialruleset', + name='description', + field=models.TextField(), + ), + migrations.AlterField( + model_name='specialruleset', + name='min_players', + field=models.IntegerField(default=2), + ), + migrations.AlterField( + model_name='specialruleset', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterUniqueTogether( + name='card', + unique_together={('suit', 'rank', 'special_card')}, + ), + migrations.AlterUniqueTogether( + name='gamedeck', + unique_together={('game', 'position')}, + ), + migrations.AlterUniqueTogether( + name='gameplayer', + unique_together={('game', 'user')}, + ), + migrations.AlterUniqueTogether( + name='lobbyplayer', + unique_together={('lobby', 'user')}, + ), + migrations.AlterUniqueTogether( + name='playerhand', + unique_together={('game', 'player', 'card')}, + ), + migrations.AlterUniqueTogether( + name='specialrulesetcard', + unique_together={('rule_set', 'card')}, + ), + migrations.DeleteModel( + name='Move', + ), + migrations.DeleteModel( + name='Turn', + ), + ] diff --git a/game/migrations/0003_turn_move.py b/game/migrations/0003_turn_move.py new file mode 100644 index 0000000..1636bbf --- /dev/null +++ b/game/migrations/0003_turn_move.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:37 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('game', '0002_remove_turn_game_remove_turn_player_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Turn', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('turn_number', models.IntegerField()), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='turns', to='game.game')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Turn', + 'verbose_name_plural': 'Turns', + 'ordering': ['turn_number'], + 'unique_together': {('game', 'turn_number')}, + }, + ), + migrations.CreateModel( + name='Move', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action_type', models.CharField(choices=[('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup')], max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('table_card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.tablecard')), + ('turn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moves', to='game.turn')), + ], + options={ + 'verbose_name': 'Move', + 'verbose_name_plural': 'Moves', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/game/models.py b/game/models.py index 71a8362..5d6d321 100644 --- a/game/models.py +++ b/game/models.py @@ -1,3 +1,1392 @@ +"""Game models for the Durak card game application. + +This module contains all the Django models that define the game logic, +lobby management, card representations, and player interactions for +the online multiplayer Durak card game. +""" + +import uuid from django.db import models -# Create your models here. + +class CardSuit(models.Model): + """Card suit model representing playing card suits (Hearts, Diamonds, etc.). + + Defines the four traditional card suits with their display names and colors. + Used as a lookup table for card generation and game logic. + + Attributes: + id (SmallAutoField): Primary key with small integer for efficiency. + name (CharField): Display name of the suit (e.g., "Hearts", "Spades"). + color (CharField): Color of the suit, either "red" or "black". + + Color Choices: + - 'red': For Hearts and Diamonds + - 'black': For Clubs and Spades + + Example: + # Create a heart suit + hearts = CardSuit.objects.create( + name="Hearts", + color="red" + ) + """ + + id = models.SmallAutoField(primary_key=True) + name = models.CharField(max_length=20) + color = models.CharField(max_length=5, choices=[('red', 'Red'), ('black', 'Black')]) + + def __str__(self): + """Return string representation of the card suit. + + Returns: + str: The name of the suit. + """ + return self.name + + def is_red(self): + """Check if the suit is red (Hearts or Diamonds). + + Returns: + bool: True if the suit is red, False otherwise. + """ + return self.color == 'red' + + class Meta: + verbose_name = 'Card Suit' + verbose_name_plural = 'Card Suits' + ordering = ['name'] + + +class CardRank(models.Model): + """Card rank model representing playing card values (Ace, King, etc.). + + Defines the ranks/values of playing cards with their display names + and numeric values for comparison during gameplay. + + Attributes: + id (SmallAutoField): Primary key with small integer for efficiency. + name (CharField): Display name of the rank (e.g., "Ace", "King"). + value (IntegerField): Numeric value used for card comparison and game logic. + + Example: + # Create an Ace card rank + ace = CardRank.objects.create( + name="Ace", + value=14 # Highest value in most variations + ) + """ + + id = models.SmallAutoField(primary_key=True) + name = models.CharField(max_length=20) + value = models.IntegerField() + + def __str__(self): + """Return string representation of the card rank. + + Returns: + str: The name of the rank. + """ + return self.name + + def is_face_card(self): + """Check if this is a face card (Jack, Queen, King). + + Returns: + bool: True if value indicates a face card (typically 11-13). + """ + return 11 <= self.value <= 13 + + class Meta: + verbose_name = 'Card Rank' + verbose_name_plural = 'Card Ranks' + ordering = ['value'] + + +class Lobby(models.Model): + """Game lobby model for organizing players before starting games. + + Represents a game room where players can gather, chat, and prepare + to start a Durak game session. Handles lobby ownership, privacy + settings, and player management. + + Attributes: + id (UUIDField): Unique identifier for the lobby. + owner (ForeignKey): Reference to the User who created the lobby. + name (CharField): Display name of the lobby. + is_private (BooleanField): Whether the lobby requires a password to join. + password_hash (CharField, optional): Hashed password for private lobbies. + status (CharField): Current lobby state. + created_at (DateTimeField): When the lobby was created. + + Related Objects: + settings: LobbySettings object with game configuration (OneToOne). + players: LobbyPlayer objects representing users in this lobby. + games: Game objects that have been played in this lobby. + messages: Chat messages sent in this lobby. + + Status Choices: + - 'waiting': Lobby is open and waiting for players + - 'playing': Game is currently in progress + - 'closed': Lobby has been closed and is no longer accessible + + Example: + # Create a public lobby + lobby = Lobby.objects.create( + owner=user, + name="Beginner's Game", + is_private=False, + status='waiting' + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + name = models.CharField(max_length=100) + is_private = models.BooleanField(default=False) + password_hash = models.CharField(max_length=128, null=True, blank=True) + status = models.CharField(max_length=10, + choices=[('waiting', 'Waiting'), ('playing', 'Playing'), ('closed', 'Closed')]) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + """Return string representation of the lobby. + + Returns: + str: The name of the lobby. + """ + return self.name + + def is_full(self): + """Check if the lobby has reached its maximum player capacity. + + Returns: + bool: True if lobby is at max capacity, False otherwise. + """ + current_players = self.players.filter(status__in=['waiting', 'ready', 'playing']).count() + return current_players >= self.settings.max_players + + def can_start_game(self): + """Check if the lobby has enough ready players to start a game. + + Returns: + bool: True if there are at least 2 ready players and lobby is waiting. + """ + if self.status != 'waiting': + return False + ready_players = self.players.filter(status='ready').count() + return ready_players >= 2 + + def get_active_players(self): + """Get all active players in the lobby. + + Returns: + QuerySet: LobbyPlayer objects with active status. + """ + return self.players.filter(status__in=['waiting', 'ready', 'playing']) + + class Meta: + verbose_name = 'Lobby' + verbose_name_plural = 'Lobbies' + ordering = ['-created_at'] + + +class LobbySettings(models.Model): + """Configuration settings for a game lobby. + + Defines the rules and parameters that will be applied to games + started within the associated lobby. Each lobby has exactly one + settings configuration. + + Attributes: + id (UUIDField): Unique identifier for the settings. + lobby (OneToOneField): Reference to the associated Lobby. + max_players (PositiveIntegerField): Maximum number of players allowed. + card_count (IntegerField): Number of cards to use in the deck. + is_transferable (BooleanField): Whether cards can be transferred between players. + neighbor_throw_only (BooleanField): Whether only neighbors can throw in additional cards. + allow_jokers (BooleanField): Whether joker cards are included in the deck. + turn_time_limit (IntegerField, optional): Time limit per turn in seconds. + special_rule_set (ForeignKey, optional): Reference to a special rule configuration. + + Card Count Choices: + - 24: Short deck (9, 10, J, Q, K, A of each suit) + - 36: Standard deck (6, 7, 8, 9, 10, J, Q, K, A of each suit) + - 52: Full deck (all cards including 2-5) + + Example: + # Create standard game settings + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + lobby = models.OneToOneField(Lobby, on_delete=models.CASCADE, related_name='settings') + max_players = models.PositiveIntegerField() + card_count = models.IntegerField(choices=[(24, '24'), (36, '36'), (52, '52')]) + is_transferable = models.BooleanField(default=False) + neighbor_throw_only = models.BooleanField(default=False) + allow_jokers = models.BooleanField(default=False) + turn_time_limit = models.IntegerField(null=True, blank=True) + special_rule_set = models.ForeignKey('SpecialRuleSet', on_delete=models.SET_NULL, null=True, blank=True) + + def __str__(self): + """Return string representation of the lobby settings. + + Returns: + str: Summary of key settings. + """ + return f"{self.lobby.name} Settings ({self.card_count} cards, {self.max_players} players)" + + def has_time_limit(self): + """Check if the lobby has a turn time limit enabled. + + Returns: + bool: True if turn_time_limit is set or not equals 0, False otherwise. + """ + if self.turn_time_limit is None: + return False + else: + return self.turn_time_limit > 0 + + def is_beginner_friendly(self): + """Check if settings are suitable for beginner players. + + Returns: + bool: True if settings use standard rules without complex features. + """ + return (not self.is_transferable and + not self.allow_jokers and + self.special_rule_set is None) + + class Meta: + verbose_name = 'Lobby Settings' + verbose_name_plural = 'Lobby Settings' + + +class LobbyPlayer(models.Model): + """Relationship model connecting users to lobbies with their status. + + Represents a player's membership in a specific lobby, tracking their + current status and readiness to play. Handles the player lifecycle + from joining to leaving the lobby. + + Attributes: + id (UUIDField): Unique identifier for the lobby membership. + lobby (ForeignKey): Reference to the Lobby the player has joined. + user (ForeignKey): Reference to the User who joined the lobby. + status (CharField): Current status of the player in the lobby. + + Status Choices: + - 'waiting': Player has joined but is not ready to start + - 'ready': Player is ready to start a game + - 'playing': Player is currently in an active game + - 'left': Player has left the lobby + + Example: + # Add a player to a lobby + player = LobbyPlayer.objects.create( + lobby=lobby, + user=user, + status='waiting' + ) + + # Mark player as ready + player.status = 'ready' + player.save() + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE, related_name='players') + user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + status = models.CharField(max_length=10, + choices=[('waiting', 'Waiting'), ('ready', 'Ready'), ('playing', 'Playing'), + ('left', 'Left')]) + + def __str__(self): + """Return string representation of the lobby player. + + Returns: + str: Username and status in the lobby. + """ + return f"{self.user.username} ({self.status}) in {self.lobby.name}" + + def is_active(self): + """Check if the player is actively participating in the lobby. + + Returns: + bool: True if player status is not 'left', False otherwise. + """ + return self.status != 'left' + + def can_start_game(self): + """Check if the player is ready to start a game. + + Returns: + bool: True if player status is 'ready', False otherwise. + """ + return self.status == 'ready' + + def leave_lobby(self): + """Mark the player as having left the lobby. + + Updates the player's status to 'left' and saves the record. + """ + self.status = 'left' + self.save(update_fields=['status']) + + class Meta: + verbose_name = 'Lobby Player' + verbose_name_plural = 'Lobby Players' + unique_together = ['lobby', 'user'] + ordering = ['lobby', 'user__username'] + + +class Game(models.Model): + """Core game session model for Durak card game. + + Represents an active or completed game session within a lobby. + Handles game state, trump card selection, and player management. + Each game is linked to a specific lobby and tracks all game-related data. + + Attributes: + id (UUIDField): Unique identifier for the game session. + lobby (ForeignKey): Reference to the Lobby where this game is played. + trump_card (ForeignKey): The card that determines the trump suit for this game. + started_at (DateTimeField): When the game session began. + finished_at (DateTimeField, optional): When the game ended (null for active games). + status (CharField): Current game state ('in_progress' or 'finished'). + loser (ForeignKey, optional): Reference to the User who lost the game. + + Related Objects: + players: GamePlayer objects representing players in this game session. + deck_cards: GameDeck objects representing cards remaining in the deck. + hands: PlayerHand objects showing which cards each player holds. + table_cards: TableCard objects representing cards currently on the table. + discarded_cards: DiscardPile objects for cards that have been discarded. + turns: Turn objects tracking the sequence of player turns. + + Status Choices: + - 'in_progress': Game is currently being played + - 'finished': Game has ended with a winner/loser determined + + Example: + # Start a new game + game = Game.objects.create( + lobby=lobby, + trump_card=selected_trump_card, + status='in_progress' + ) + + # End the game with a loser + game.status = 'finished' + game.loser = losing_player + game.finished_at = timezone.now() + game.save() + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE) + trump_card = models.ForeignKey('Card', on_delete=models.PROTECT, related_name='as_trump') + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=15, choices=[('in_progress', 'In Progress'), ('finished', 'Finished')]) + loser = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True) + + def __str__(self): + """Return string representation of the game. + + Returns: + str: Game info with lobby name and status. + """ + return f"Game in {self.lobby.name} ({self.status})" + + def is_active(self): + """Check if the game is currently in progress. + + Returns: + bool: True if status is 'in_progress', False otherwise. + """ + return self.status == 'in_progress' + + def get_trump_suit(self): + """Get the trump suit for this game. + + Returns: + CardSuit: The suit of the trump card. + """ + return self.trump_card.suit + + def get_player_count(self): + """Get the number of players in this game. + + Returns: + int: Count of GamePlayer objects associated with this game. + """ + return self.players.count() + + def get_winner(self): + """Get the winner of the game (all players except the loser). + + Returns: + QuerySet: GamePlayer objects representing winners, or None if game is active. + """ + if self.status != 'finished' or not self.loser: + return None + return self.players.exclude(user=self.loser) + + class Meta: + verbose_name = 'Game' + verbose_name_plural = 'Games' + ordering = ['-started_at'] + + +class GamePlayer(models.Model): + """Relationship model connecting users to game sessions with game-specific data. + + Represents a player's participation in a specific game, tracking their + position, remaining cards, and other game-state information. + + Attributes: + id (UUIDField): Unique identifier for the game participation. + game (ForeignKey): Reference to the Game session. + user (ForeignKey): Reference to the participating User. + seat_position (IntegerField): Player's position around the table (turn order). + cards_remaining (IntegerField): Number of cards currently in player's hand. + + Example: + # Add a player to a game + game_player = GamePlayer.objects.create( + game=game, + user=user, + seat_position=1, + cards_remaining=6 + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='players') + user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + seat_position = models.IntegerField() + cards_remaining = models.IntegerField() + + def __str__(self): + """Return string representation of the game player. + + Returns: + str: Player info with username and card count. + """ + return f"{self.user.username} ({self.cards_remaining} cards) - Position {self.seat_position}" + + def has_cards(self): + """Check if the player still has cards in their hand. + + Returns: + bool: True if cards_remaining > 0, False otherwise. + """ + return self.cards_remaining > 0 + + def is_eliminated(self): + """Check if the player has been eliminated (no cards left). + + Returns: + bool: True if player has no cards remaining, False otherwise. + """ + return self.cards_remaining == 0 + + def get_hand_cards(self): + """Get all cards currently in this player's hand. + + Returns: + QuerySet: PlayerHand objects representing cards in hand. + """ + return PlayerHand.objects.filter(game=self.game, player=self.user) + + class Meta: + verbose_name = 'Game Player' + verbose_name_plural = 'Game Players' + unique_together = ['game', 'user'] + ordering = ['seat_position'] + + +class Card(models.Model): + """Playing card model combining suit, rank, and optional special properties. + + Represents an individual playing card with its suit, rank, and any + special abilities. Cards can be standard playing cards or special + cards with unique effects. + + Attributes: + id (UUIDField): Unique identifier for the card. + suit (ForeignKey): Reference to the CardSuit (Hearts, Diamonds, etc.). + rank (ForeignKey): Reference to the CardRank (Ace, King, etc.). + special_card (ForeignKey, optional): Reference to special card effects if applicable. + + Related Objects: + attack_card: TableCard objects where this card is the attacking card. + defense_card: TableCard objects where this card is the defending card. + as_trump: Game objects where this card serves as the trump card. + + Example: + # Create a standard playing card + card = Card.objects.create( + suit=hearts_suit, + rank=ace_rank + ) + + # Create a special card + special_card = Card.objects.create( + suit=spades_suit, + rank=joker_rank, + special_card=skip_effect + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + suit = models.ForeignKey(CardSuit, on_delete=models.CASCADE) + rank = models.ForeignKey(CardRank, on_delete=models.CASCADE) + special_card = models.ForeignKey('SpecialCard', on_delete=models.SET_NULL, null=True, blank=True) + + def __str__(self): + """Return string representation of the card. + + Returns: + str: Card rank and suit (e.g., "Ace of Hearts"). + """ + base_name = f"{self.rank.name} of {self.suit.name}" + if self.special_card: + return f"{base_name} ({self.special_card.name})" + return base_name + + def is_trump(self, trump_suit): + """Check if this card belongs to the trump suit. + + Args: + trump_suit (CardSuit): The current trump suit for the game. + + Returns: + bool: True if card's suit matches trump suit, False otherwise. + """ + return self.suit == trump_suit + + def is_special(self): + """Check if this card has special effects. + + Returns: + bool: True if special_card is set, False otherwise. + """ + return self.special_card is not None + + def can_beat(self, other_card, trump_suit): + """Check if this card can beat another card according to Durak rules. + + Args: + other_card (Card): The card to compare against. + trump_suit (CardSuit): The current trump suit. + + Returns: + bool: True if this card can beat the other card, False otherwise. + """ + # Trump cards beat non-trump cards + if self.is_trump(trump_suit) and not other_card.is_trump(trump_suit): + return True + + # Non-trump cannot beat trump + if not self.is_trump(trump_suit) and other_card.is_trump(trump_suit): + return False + + # Same suit comparison by rank value + if self.suit == other_card.suit: + return self.rank.value > other_card.rank.value + + # Different non-trump suits cannot beat each other + return False + + class Meta: + verbose_name = 'Card' + verbose_name_plural = 'Cards' + unique_together = ['suit', 'rank', 'special_card'] + + +class SpecialCard(models.Model): + """Special card effects model for custom game mechanics. + + Defines special abilities that can be applied to cards to create + unique gameplay mechanics beyond standard Durak rules. Each special + card type has a specific effect and description. + + Attributes: + id (UUIDField): Unique identifier for the special card type. + name (CharField): Display name of the special effect. + effect_type (CharField): Category of the special effect. + effect_value (JSONField): JSON data containing effect parameters. + description (TextField): Human-readable description of the effect. + + Effect Types: + - 'skip': Skip the next player's turn + - 'reverse': Reverse turn order + - 'draw': Force target player to draw additional cards + - 'custom': Custom effect with parameters in effect_value + + Example: + # Create a skip effect card + skip_effect = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + effect_value={}, + description="Next player loses their turn" + ) + + # Create a draw effect card + draw_effect = SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + effect_value={"card_count": 2}, + description="Target player draws 2 additional cards" + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=50) + effect_type = models.CharField( + max_length=20, + choices=[ + ('skip', 'Skip Turn'), + ('reverse', 'Reverse Order'), + ('draw', 'Draw Cards'), + ('custom', 'Custom Effect') + ] + ) + effect_value = models.JSONField(default=dict, blank=True) + description = models.TextField(null=True, blank=True) + + def __str__(self): + """Return string representation of the special card. + + Returns: + str: The name of the special card effect. + """ + return self.name + + def get_effect_description(self): + """Get a formatted description of the effect with parameters. + + Returns: + str: Description with effect values interpolated. + """ + if self.effect_type == 'draw' and 'card_count' in self.effect_value: + return f"{self.description} ({self.effect_value['card_count']} cards)" + return self.description + + def is_targetable(self): + """Check if this effect requires targeting a specific player. + + Returns: + bool: True if effect targets other players, False if self-affecting. + """ + return self.effect_type in ['draw', 'skip'] + + def can_be_countered(self): + """Check if this special effect can be countered by other cards. + + Returns: + bool: True if effect can be countered, False otherwise. + """ + return self.effect_value.get('counterable', True) + + class Meta: + verbose_name = 'Special Card' + verbose_name_plural = 'Special Cards' + ordering = ['name'] + + +class SpecialRuleSet(models.Model): + """Special rule configuration for advanced game variants. + + Defines collections of special rules and cards that can be applied + to lobbies to create custom game experiences. Each rule set can + specify minimum player requirements and special card inclusions. + + Attributes: + id (UUIDField): Unique identifier for the rule set. + name (CharField): Display name of the rule set. + description (TextField): Detailed description of the rules. + min_players (IntegerField): Minimum players required for this rule set. + + Related Objects: + special_cards: SpecialCard objects included in this rule set (M2M through SpecialRuleSetCard). + lobby_settings: LobbySettings objects using this rule set. + + Example: + # Create a beginner-friendly rule set + beginner_rules = SpecialRuleSet.objects.create( + name="Beginner Special", + description="Simple special cards for new players", + min_players=2 + ) + + # Create an advanced rule set + advanced_rules = SpecialRuleSet.objects.create( + name="Master's Challenge", + description="Complex special effects for experienced players", + min_players=4 + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=100) + description = models.TextField() + min_players = models.IntegerField(default=2) + + def __str__(self): + """Return string representation of the rule set. + + Returns: + str: The name of the rule set. + """ + return self.name + + def get_special_card_count(self): + """Get the number of special cards in this rule set. + + Returns: + int: Count of special cards associated with this rule set. + """ + return self.specialrulesetcard_set.count() + + def is_compatible_with_player_count(self, player_count): + """Check if this rule set can be used with the given number of players. + + Args: + player_count (int): Number of players in the game. + + Returns: + bool: True if player count meets minimum requirement. + """ + return player_count >= self.min_players + + def get_enabled_special_cards(self): + """Get all special cards that are enabled in this rule set. + + Returns: + QuerySet: SpecialCard objects that are active in this rule set. + """ + return SpecialCard.objects.filter( + specialrulesetcard__rule_set=self, + specialrulesetcard__is_enabled=True + ) + + def can_be_used_in_lobby(self, lobby_settings): + """Check if this rule set is compatible with lobby settings. + + Args: + lobby_settings (LobbySettings): The lobby settings to check against. + + Returns: + bool: True if compatible with lobby configuration. + """ + return (self.is_compatible_with_player_count(lobby_settings.max_players) and + lobby_settings.allow_jokers) # Special cards require jokers enabled + + class Meta: + verbose_name = 'Special Rule Set' + verbose_name_plural = 'Special Rule Sets' + ordering = ['name'] + + +class SpecialRuleSetCard(models.Model): + """Association model linking special cards to rule sets with configuration. + + Defines which special cards are included in specific rule sets and + how they should be configured within that context. Allows for + fine-tuned control over special card availability and behavior. + + Attributes: + id (UUIDField): Unique identifier for the association. + rule_set (ForeignKey): Reference to the SpecialRuleSet. + card (ForeignKey): Reference to the SpecialCard. + is_enabled (BooleanField): Whether this card is active in the rule set. + + Example: + # Add a special card to a rule set + SpecialRuleSetCard.objects.create( + rule_set=beginner_rules, + card=skip_effect, + is_enabled=True + ) + + # Add but disable a complex card for beginners + SpecialRuleSetCard.objects.create( + rule_set=beginner_rules, + card=complex_effect, + is_enabled=False + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + rule_set = models.ForeignKey(SpecialRuleSet, on_delete=models.CASCADE) + card = models.ForeignKey(SpecialCard, on_delete=models.CASCADE) + is_enabled = models.BooleanField(default=True) + + def __str__(self): + """Return string representation of the rule set card association. + + Returns: + str: Rule set and card names with status. + """ + status = "enabled" if self.is_enabled else "disabled" + return f"{self.card.name} in {self.rule_set.name} ({status})" + + def toggle_enabled(self): + """Toggle the enabled status of this card in the rule set. + + Returns: + bool: New enabled status after toggling. + """ + self.is_enabled = not self.is_enabled + self.save(update_fields=['is_enabled']) + return self.is_enabled + + def can_be_used_in_game(self, game): + """Check if this special card can be used in a specific game. + + Args: + game (Game): The game session to check compatibility with. + + Returns: + bool: True if the card is enabled and game allows special rules. + """ + if not self.is_enabled: + return False + + lobby_settings = game.lobby.settings + return (lobby_settings.special_rule_set == self.rule_set and + lobby_settings.allow_jokers) + + class Meta: + verbose_name = 'Special Rule Set Card' + verbose_name_plural = 'Special Rule Set Cards' + unique_together = ['rule_set', 'card'] + ordering = ['rule_set__name', 'card__name'] + + +class GameDeck(models.Model): + """Model representing cards remaining in the deck during a game. + + Tracks the position and order of cards in the game deck. Cards are + drawn from this deck when players need to replenish their hands. + + Attributes: + id (UUIDField): Unique identifier for the deck entry. + game (ForeignKey): Reference to the Game session. + card (ForeignKey): Reference to the Card in the deck. + position (IntegerField): Position of the card in the deck (for draw order). + + Example: + # Add a card to the game deck + GameDeck.objects.create( + game=game, + card=card, + position=1 + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + card = models.ForeignKey(Card, on_delete=models.CASCADE) + position = models.IntegerField() + + def __str__(self): + """Return string representation of the deck card. + + Returns: + str: Card info with position in deck. + """ + return f"{self.card} at position {self.position} in {self.game}" + + @classmethod + def get_top_card(cls, game): + """Get the top card from the deck (lowest position number). + + Args: + game (Game): The game to get the top card for. + + Returns: + GameDeck: The deck entry with the lowest position, or None if deck is empty. + """ + return cls.objects.filter(game=game).order_by('position').first() + + @classmethod + def draw_card(cls, game): + """Draw and remove the top card from the deck. + + Args: + game (Game): The game to draw a card from. + + Returns: + Card: The drawn card, or None if deck is empty. + """ + deck_card = cls.get_top_card(game) + if deck_card: + card = deck_card.card + deck_card.delete() + return card + return None + + def is_last_card(self): + """Check if this is the last card in the deck. + + Returns: + bool: True if no other cards have higher positions, False otherwise. + """ + return not GameDeck.objects.filter( + game=self.game, + position__gt=self.position + ).exists() + + class Meta: + verbose_name = 'Game Deck Card' + verbose_name_plural = 'Game Deck Cards' + unique_together = ['game', 'position'] + ordering = ['position'] + + +class PlayerHand(models.Model): + """Model representing cards in a player's hand during a game. + + Tracks which cards each player holds, with optional ordering + information for UI display purposes. + + Attributes: + id (UUIDField): Unique identifier for the hand entry. + game (ForeignKey): Reference to the Game session. + player (ForeignKey): Reference to the User who holds the card. + card (ForeignKey): Reference to the Card in the player's hand. + order_in_hand (IntegerField, optional): Display order of card in hand. + + Example: + # Add a card to a player's hand + PlayerHand.objects.create( + game=game, + player=user, + card=card, + order_in_hand=1 + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + card = models.ForeignKey(Card, on_delete=models.CASCADE) + order_in_hand = models.IntegerField(null=True, blank=True) + + def __str__(self): + """Return string representation of the hand card. + + Returns: + str: Player and card information. + """ + return f"{self.player.username} holds {self.card} in {self.game}" + + @classmethod + def get_player_hand(cls, game, player): + """Get all cards in a specific player's hand for a game. + + Args: + game (Game): The game session. + player (User): The player whose hand to retrieve. + + Returns: + QuerySet: PlayerHand objects ordered by order_in_hand. + """ + return cls.objects.filter( + game=game, + player=player + ).order_by('order_in_hand') + + @classmethod + def get_hand_size(cls, game, player): + """Get the number of cards in a player's hand. + + Args: + game (Game): The game session. + player (User): The player whose hand size to count. + + Returns: + int: Number of cards in the player's hand. + """ + return cls.objects.filter(game=game, player=player).count() + + def remove_from_hand(self): + """Remove this card from the player's hand. + + Deletes the PlayerHand record and updates the GamePlayer's + cards_remaining counter. + """ + game_player = GamePlayer.objects.get(game=self.game, user=self.player) + game_player.cards_remaining = max(0, game_player.cards_remaining - 1) + game_player.save(update_fields=['cards_remaining']) + self.delete() + + class Meta: + verbose_name = 'Player Hand Card' + verbose_name_plural = 'Player Hand Cards' + unique_together = ['game', 'player', 'card'] + ordering = ['order_in_hand'] + + +class TableCard(models.Model): + """Model representing attack and defense card pairs on the table. + + During a Durak game round, attacking cards are placed on the table + and can be defended by appropriate defending cards. This model + tracks these attack-defense pairs. + + Attributes: + id (UUIDField): Unique identifier for the table card pair. + game (ForeignKey): Reference to the Game session. + attack_card (ForeignKey): The card used for attack. + defense_card (ForeignKey, optional): The card used for defense (null if undefended). + + Related Objects: + moves: Move objects referencing this table card pair. + + Example: + # Place an attack card on the table + table_card = TableCard.objects.create( + game=game, + attack_card=seven_of_hearts + ) + + # Defend the attack + table_card.defense_card = ten_of_hearts + table_card.save() + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + attack_card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='attack_card') + defense_card = models.ForeignKey(Card, on_delete=models.SET_NULL, null=True, blank=True, + related_name='defense_card') + + def __str__(self): + """Return string representation of the table card. + + Returns: + str: Attack and defense card information. + """ + if self.defense_card: + return f"{self.attack_card} defended by {self.defense_card}" + return f"{self.attack_card} (undefended)" + + def is_defended(self): + """Check if the attack card has been defended. + + Returns: + bool: True if defense_card is set, False otherwise. + """ + return self.defense_card is not None + + def is_valid_defense(self, defense_card, trump_suit): + """Check if a card can validly defend this attack. + + Args: + defense_card (Card): The card being used for defense. + trump_suit (CardSuit): The current trump suit. + + Returns: + bool: True if the defense is valid according to Durak rules. + """ + if self.is_defended(): + return False # Already defended + + return defense_card.can_beat(self.attack_card, trump_suit) + + def defend_with(self, defense_card, trump_suit): + """Attempt to defend this attack with a card. + + Args: + defense_card (Card): The card being used for defense. + trump_suit (CardSuit): The current trump suit. + + Returns: + bool: True if defense was successful, False otherwise. + """ + if self.is_valid_defense(defense_card, trump_suit): + self.defense_card = defense_card + self.save(update_fields=['defense_card']) + return True + return False + + class Meta: + verbose_name = 'Table Card' + verbose_name_plural = 'Table Cards' + ordering = ['id'] + + +class DiscardPile(models.Model): + """Model representing cards that have been discarded from the game. + + After successful defense rounds or when cards are played out, + they are moved to the discard pile and removed from active play. + + Attributes: + id (UUIDField): Unique identifier for the discard entry. + game (ForeignKey): Reference to the Game session. + card (ForeignKey): Reference to the discarded Card. + position (IntegerField, optional): Order in which cards were discarded. + + Example: + # Discard cards after a successful defense + DiscardPile.objects.create( + game=game, + card=attack_card, + position=1 + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + card = models.ForeignKey(Card, on_delete=models.CASCADE) + position = models.IntegerField(null=True, blank=True) + + def __str__(self): + """Return string representation of the discarded card. + + Returns: + str: Card and position information. + """ + pos_info = f" (position {self.position})" if self.position else "" + return f"Discarded {self.card}{pos_info}" + + @classmethod + def discard_cards(cls, game, cards): + """Discard multiple cards at once. + + Args: + game (Game): The game session. + cards (list): List of Card objects to discard. + + Returns: + list: List of created DiscardPile objects. + """ + last_position = cls.objects.filter(game=game).count() + discard_entries = [] + + +class Turn(models.Model): + """Turn tracking model for managing player turn sequence in games. + + Represents individual turns within a game session, tracking which player's + turn it is and maintaining the sequential order of gameplay. Each turn + can contain multiple moves (attack, defend, pickup). + + Attributes: + id (UUIDField): Unique identifier for the turn. + game (ForeignKey): Reference to the Game session this turn belongs to. + player (ForeignKey): Reference to the User whose turn it is. + turn_number (IntegerField): Sequential number of this turn in the game. + + Related Objects: + moves: Move objects that occurred during this turn. + + Example: + # Create a new turn + turn = Turn.objects.create( + game=game, + player=current_player, + turn_number=1 + ) + + # Get the next turn number + next_turn = Turn.objects.filter(game=game).count() + 1 + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + 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. + + Returns: + 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. + + Returns: + 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). + + Returns: + 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. + + Args: + game (Game): The game to get the current turn for. + + Returns: + 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. + + Args: + game (Game): The game to create a turn for. + player (User): The player whose turn it will be. + + Returns: + Turn: The newly created turn object. + """ + next_number = cls.objects.filter(game=game).count() + 1 + return cls.objects.create( + game=game, + player=player, + turn_number=next_number + ) + + class Meta: + verbose_name = 'Turn' + verbose_name_plural = 'Turns' + unique_together = ['game', 'turn_number'] + ordering = ['turn_number'] + + +class Move(models.Model): + """Game move model representing individual player actions during gameplay. + + Tracks specific actions taken by players during their turns, such as + attacking with cards, defending attacks, or picking up cards. Each move + is associated with a turn and references the relevant table cards. + + Attributes: + id (UUIDField): Unique identifier for the move. + turn (ForeignKey): Reference to the Turn this move belongs to. + table_card (ForeignKey): Reference to the TableCard affected by this move. + action_type (CharField): Type of action performed (attack, defend, pickup). + created_at (DateTimeField): Timestamp when the move was made. + + Action Types: + - 'attack': Player places an attacking card on the table + - 'defend': Player defends an attack with an appropriate card + - 'pickup': Player picks up undefended cards from the table + + Example: + # Record an attack move + attack_move = Move.objects.create( + turn=current_turn, + table_card=table_card, + action_type='attack' + ) + + # Record a defense move + defense_move = Move.objects.create( + turn=current_turn, + table_card=table_card, + action_type='defend' + ) + """ + + ACTION_CHOICES = [ + ('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. + + Returns: + 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. + + Returns: + 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. + + Returns: + 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. + + Returns: + 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. + + Returns: + 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. + + Args: + game (Game): The game to get moves for. + + Returns: + 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. + + Args: + game (Game): The game to search in. + player (User): The player whose moves to retrieve. + + Returns: + 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' + ordering = ['created_at'] diff --git a/game/tests.py b/game/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/game/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/game/tests/__init__.py b/game/tests/__init__.py new file mode 100644 index 0000000..b5e4f5b --- /dev/null +++ b/game/tests/__init__.py @@ -0,0 +1,5 @@ +"""Test suite for game app. + +This package contains comprehensive tests for all game-related models, +including cards, lobbies, games, players, turns, and special rules. +""" diff --git a/game/tests/test_card_models.py b/game/tests/test_card_models.py new file mode 100644 index 0000000..8cc9028 --- /dev/null +++ b/game/tests/test_card_models.py @@ -0,0 +1,315 @@ +"""Tests for card-related models: CardSuit, CardRank, and Card. + +This module tests the creation, methods, and relationships of card components +used in the game, including basic card properties, trump logic, and special cards. +""" + +import pytest +from django.db import IntegrityError +from game.models import CardSuit, CardRank, Card, SpecialCard + + +@pytest.mark.django_db +class TestCardSuitModel: + """Test suite for CardSuit model.""" + + def test_card_suit_creation(self): + """Test that CardSuit instances are created correctly with name and color.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + + assert hearts.name == "Hearts" + assert hearts.color == "red" + assert spades.color == "black" + + def test_card_suit_str_representation(self): + """Test string representation returns suit name.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + + assert str(hearts) == "Hearts" + assert str(spades) == "Spades" + + def test_is_red_method(self): + """Test is_red() method returns correct boolean based on color.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + + assert hearts.is_red() is True + assert spades.is_red() is False + + def test_card_suit_ordering(self): + """Test that CardSuit instances are ordered alphabetically by name.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + diamonds = CardSuit.objects.create(name="Diamonds", color="red") + clubs = CardSuit.objects.create(name="Clubs", color="black") + + suits = list(CardSuit.objects.all()) + + assert suits[0].name == "Clubs" + assert suits[1].name == "Diamonds" + assert suits[2].name == "Hearts" + assert suits[3].name == "Spades" + + def test_card_suit_color_choices(self): + """Test that only valid color choices (red/black) are accepted.""" + # Valid colors should work + valid_suit = CardSuit.objects.create(name="Test", color="red") + assert valid_suit.color == "red" + + valid_suit_black = CardSuit.objects.create(name="Test2", color="black") + assert valid_suit_black.color == "black" + + +@pytest.mark.django_db +class TestCardRankModel: + """Test suite for CardRank model.""" + + def test_card_rank_creation(self): + """Test that CardRank instances are created correctly with name and value.""" + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + + assert ace.name == "Ace" + assert ace.value == 14 + assert king.value == 13 + + def test_card_rank_str_representation(self): + """Test string representation returns rank name.""" + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + + assert str(ace) == "Ace" + assert str(king) == "King" + + def test_is_face_card_method(self): + """Test is_face_card() method identifies Jack, Queen, and King. + + Face cards are defined as cards with values 11-13: + - Jack (11), Queen (12), King (13) are face cards + - Ace (14) and number cards are not face cards + """ + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + queen = CardRank.objects.create(name="Queen", value=12) + jack = CardRank.objects.create(name="Jack", value=11) + six = CardRank.objects.create(name="Six", value=6) + + assert king.is_face_card() is True + assert queen.is_face_card() is True + assert jack.is_face_card() is True + assert ace.is_face_card() is False + assert six.is_face_card() is False + + def test_card_rank_ordering(self): + """Test that CardRank instances are ordered by value ascending.""" + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + jack = CardRank.objects.create(name="Jack", value=11) + six = CardRank.objects.create(name="Six", value=6) + + ranks = list(CardRank.objects.all()) + + assert ranks[0].value == 6 + assert ranks[1].value == 11 + assert ranks[2].value == 13 + assert ranks[3].value == 14 + + +@pytest.mark.django_db +class TestCardModel: + """Test suite for Card model.""" + + def test_card_creation(self, card_suits, card_ranks): + """Test that Card instances are created correctly with suit and rank.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + + assert ace_of_hearts.suit == card_suits['hearts'] + assert ace_of_hearts.rank == card_ranks['ace'] + assert ace_of_hearts.special_card is None + + def test_card_str_representation(self, card_suits, card_ranks): + """Test string representation formats as 'Rank of Suit'.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + king_of_spades = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ) + + assert str(ace_of_hearts) == "Ace of Hearts" + assert str(king_of_spades) == "King of Spades" + + def test_card_str_with_special_card(self, card_suits, card_ranks): + """Test string representation includes special card name in parentheses.""" + special = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + description="Skip next player's turn" + ) + special_card = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['ace'], + special_card=special + ) + + assert str(special_card) == "Ace of Spades (Skip Turn)" + + def test_is_trump_method(self, card_suits, card_ranks): + """Test is_trump() method correctly identifies trump suit cards.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + king_of_spades = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ) + + # Hearts is trump + assert ace_of_hearts.is_trump(card_suits['hearts']) is True + assert ace_of_hearts.is_trump(card_suits['spades']) is False + + # Spades is trump + assert king_of_spades.is_trump(card_suits['spades']) is True + assert king_of_spades.is_trump(card_suits['hearts']) is False + + def test_is_special_method(self, card_suits, card_ranks): + """Test is_special() method identifies cards with special effects.""" + normal_card = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + + special = SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + description="Draw 2 cards" + ) + special_card = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'], + special_card=special + ) + + assert normal_card.is_special() is False + assert special_card.is_special() is True + + def test_can_beat_trump_vs_non_trump(self, card_suits, card_ranks): + """Test that any trump card beats any non-trump card. + + In the game, trump cards always beat non-trump cards regardless of rank. + Even a low-value trump (e.g., Seven of Hearts) beats a high-value + non-trump (e.g., King of Spades) when Hearts is trump. + """ + seven_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ) + king_of_spades = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ) + + # Hearts is trump + trump_suit = card_suits['hearts'] + + # Low trump beats high non-trump + assert seven_of_hearts.can_beat(king_of_spades, trump_suit) is True + + # Non-trump cannot beat trump + assert king_of_spades.can_beat(seven_of_hearts, trump_suit) is False + + def test_can_beat_same_suit(self, card_suits, card_ranks): + """Test card comparison within the same suit uses rank value.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + seven_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ) + + trump_suit = card_suits['spades'] # Neither Hearts card is trump + + # Higher rank beats lower rank in same suit + assert ace_of_hearts.can_beat(seven_of_hearts, trump_suit) is True + + # Lower rank cannot beat higher rank + assert seven_of_hearts.can_beat(ace_of_hearts, trump_suit) is False + + def test_can_beat_different_non_trump_suits(self, card_suits, card_ranks): + """Test that cards of different non-trump suits cannot beat each other. + + In the game, you can only beat a card with: + 1. A higher card of the same suit, OR + 2. Any trump card (if the original card is not trump) + + Cards of different non-trump suits cannot beat each other. + """ + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + six_of_diamonds = Card.objects.create( + suit=card_suits['diamonds'], + rank=card_ranks['six'] + ) + + trump_suit = card_suits['spades'] # Hearts and Diamonds are both non-trump + + # Different non-trump suits cannot beat each other + assert ace_of_hearts.can_beat(six_of_diamonds, trump_suit) is False + assert six_of_diamonds.can_beat(ace_of_hearts, trump_suit) is False + + def test_can_beat_trump_vs_trump(self, card_suits, card_ranks): + """Test trump card comparison uses rank value.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + seven_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ) + + trump_suit = card_suits['hearts'] # Both cards are trump + + # Higher trump beats lower trump + assert ace_of_hearts.can_beat(seven_of_hearts, trump_suit) is True + assert seven_of_hearts.can_beat(ace_of_hearts, trump_suit) is False + + def test_card_unique_together_constraint(self, card_suits, card_ranks): + """Test that cards with same suit, rank, and special_card are unique. + + The database enforces uniqueness on the combination of suit, rank, + and special_card to prevent duplicate cards in the system. + """ + # Create a special card first + special = SpecialCard.objects.create( + name="Test Special", + effect_type="skip", + description="Test" + ) + + # Create first card with special_card + Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'], + special_card=special + ) + + # Creating duplicate with same suit, rank, and special_card should fail + with pytest.raises(IntegrityError): + Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'], + special_card=special + ) diff --git a/game/tests/test_deck_models.py b/game/tests/test_deck_models.py new file mode 100644 index 0000000..50683dc --- /dev/null +++ b/game/tests/test_deck_models.py @@ -0,0 +1,153 @@ +"""Tests for deck and card management models. + +This module tests models responsible for managing the state of cards in the game: +- GameDeck: The main draw pile. +- PlayerHand: Cards held by a player. +- TableCard: Attacking and defending cards in play. +- DiscardPile: Cards that are out of play. +""" + +import pytest +from game.models import ( + GameDeck, PlayerHand, TableCard, DiscardPile, GamePlayer +) + + +@pytest.mark.django_db +class TestGameDeckModel: + """Test suite for the GameDeck model.""" + + def test_game_deck_creation(self, basic_game, basic_cards): + """Tests that GameDeck entries are created correctly.""" + deck_card = GameDeck.objects.create( + game=basic_game, card=basic_cards['ace_hearts'], position=1 + ) + assert deck_card.game == basic_game + assert deck_card.card == basic_cards['ace_hearts'] + assert deck_card.position == 1 + + def test_get_top_card(self, basic_game, basic_cards): + """Tests get_top_card() returns the card with the lowest position.""" + card1 = GameDeck.objects.create(game=basic_game, card=basic_cards['ace_hearts'], position=1) + GameDeck.objects.create(game=basic_game, card=basic_cards['king_spades'], position=2) + + assert GameDeck.get_top_card(basic_game) == card1 + + def test_draw_card(self, basic_game, basic_cards): + """Tests draw_card() removes and returns the top card.""" + GameDeck.objects.create(game=basic_game, card=basic_cards['ace_hearts'], position=1) + GameDeck.objects.create(game=basic_game, card=basic_cards['king_spades'], position=2) + + drawn_card = GameDeck.draw_card(basic_game) + assert drawn_card == basic_cards['ace_hearts'] + assert GameDeck.objects.filter(game=basic_game).count() == 1 + assert GameDeck.get_top_card(basic_game).card == basic_cards['king_spades'] + + def test_is_last_card(self, basic_game, basic_cards): + """Tests is_last_card() correctly identifies the final card.""" + card1 = GameDeck.objects.create(game=basic_game, card=basic_cards['ace_hearts'], position=1) + assert card1.is_last_card() is True + card2 = GameDeck.objects.create(game=basic_game, card=basic_cards['king_spades'], position=2) + assert card1.is_last_card() is False + assert card2.is_last_card() is True + + +@pytest.mark.django_db +class TestPlayerHandModel: + """Test suite for the PlayerHand model.""" + + def test_player_hand_creation(self, basic_game, test_user, basic_cards): + """Tests that PlayerHand entries are created correctly.""" + hand_card = PlayerHand.objects.create( + game=basic_game, player=test_user, card=basic_cards['ace_hearts'], order_in_hand=1 + ) + assert hand_card.game == basic_game + assert hand_card.player == test_user + assert hand_card.card == basic_cards['ace_hearts'] + + def test_get_player_hand(self, basic_game, test_user, basic_cards): + """Tests get_player_hand() returns all cards for a player, ordered.""" + PlayerHand.objects.create(game=basic_game, player=test_user, card=basic_cards['ace_hearts'], order_in_hand=2) + PlayerHand.objects.create(game=basic_game, player=test_user, card=basic_cards['king_spades'], order_in_hand=1) + + hand = list(PlayerHand.get_player_hand(basic_game, test_user)) + assert len(hand) == 2 + assert hand[0].card == basic_cards['king_spades'] + assert hand[1].card == basic_cards['ace_hearts'] + + def test_remove_from_hand(self, basic_game, test_user, basic_cards): + """Tests remove_from_hand() deletes the card and updates the player's card count.""" + game_player = GamePlayer.objects.create( + game=basic_game, user=test_user, seat_position=1, cards_remaining=1 + ) + hand_card = PlayerHand.objects.create( + game=basic_game, player=test_user, card=basic_cards['ace_hearts'] + ) + hand_card.remove_from_hand() + + game_player.refresh_from_db() + assert not PlayerHand.objects.filter(pk=hand_card.pk).exists() + assert game_player.cards_remaining == 0 + + +@pytest.mark.django_db +class TestTableCardModel: + """Test suite for the TableCard model.""" + + def test_table_card_creation(self, basic_game, basic_cards): + """Tests that TableCard instances are created correctly.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['seven_hearts'] + ) + assert table_card.game == basic_game + assert table_card.attack_card == basic_cards['seven_hearts'] + assert table_card.defense_card is None + + def test_is_defended(self, basic_game, basic_cards): + """Tests is_defended() method.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['seven_hearts'] + ) + assert table_card.is_defended() is False + table_card.defense_card = basic_cards['ace_hearts'] + assert table_card.is_defended() is True + + def test_defend_with_valid_card(self, basic_game, card_suits, basic_cards): + """Tests defend_with() successfully updates defense_card with a valid defense.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['seven_hearts'] + ) + # Trump suit is Hearts, so any Heart can beat another Heart if it's higher rank + trump_suit = card_suits['hearts'] + result = table_card.defend_with(basic_cards['ace_hearts'], trump_suit) + + assert result is True + table_card.refresh_from_db() + assert table_card.defense_card == basic_cards['ace_hearts'] + + def test_defend_with_invalid_card(self, basic_game, card_suits, basic_cards): + """Tests defend_with() fails to update with an invalid defense card.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['ace_hearts'] + ) + # seven_hearts cannot beat ace_hearts + trump_suit = card_suits['hearts'] + result = table_card.defend_with(basic_cards['seven_hearts'], trump_suit) + + assert result is False + table_card.refresh_from_db() + assert table_card.defense_card is None + + +@pytest.mark.django_db +class TestDiscardPileModel: + """Test suite for the DiscardPile model.""" + + def test_discard_pile_creation(self, basic_game, basic_cards): + """Tests that DiscardPile entries are created correctly.""" + discarded = DiscardPile.objects.create( + game=basic_game, card=basic_cards['ace_hearts'], position=1 + ) + assert discarded.game == basic_game + assert discarded.card == basic_cards['ace_hearts'] + assert discarded.position == 1 diff --git a/game/tests/test_export_import_db.py b/game/tests/test_export_import_db.py new file mode 100644 index 0000000..39bceb4 --- /dev/null +++ b/game/tests/test_export_import_db.py @@ -0,0 +1,46 @@ +"""Tests for database export/import management commands. + +This module contains tests that exercise the `export_db` and `import_db` +Django management commands to ensure data exported to JSON can be imported +back and recreate the expected model instances. +""" + +import pytest + +from django.core.management import call_command + +from game.models import Lobby +from accounts.models import User + + +@pytest.mark.django_db +def test_export_import_db_creates_objects(user_factory, tmp_path): + """Verify that exporting the DB to JSON and re-importing recreates objects. + + The test performs the following steps: + 1. Create a test user and a Lobby owned by that user. + 2. Export the database to a temporary JSON file using the ``export_db`` + management command. + 3. Remove the created objects from the database to simulate a fresh import. + 4. Run the ``import_db`` management command to import from the JSON file. + 5. Assert that the Lobby and User objects exist after import. + """ + + user = user_factory(username="player1") + lobby_name = "TestLobby" + Lobby.objects.create(owner=user, name=lobby_name) + + # Export to temp file using --output + tmp_file = tmp_path / "backup.json" + call_command("export_db", f"--output={str(tmp_file)}") + + # Clear DB (simulate fresh import) + Lobby.objects.all().delete() + User.objects.filter(username="player1").delete() + + # Import back + call_command("import_db", str(tmp_file)) + + # Assertions + assert Lobby.objects.filter(name=lobby_name).exists() + assert User.objects.filter(username="player1").exists() diff --git a/game/tests/test_game_models.py b/game/tests/test_game_models.py new file mode 100644 index 0000000..2ba822e --- /dev/null +++ b/game/tests/test_game_models.py @@ -0,0 +1,280 @@ +"""Tests for game-related models: Game and GamePlayer. + +This module tests game creation, player management, game state tracking, +and winner determination. +""" + +import pytest +from django.db import IntegrityError +from django.utils import timezone +from game.models import Game, GamePlayer, PlayerHand, Card, CardSuit, CardRank + + +@pytest.mark.django_db +class TestGameModel: + """Test suite for Game model.""" + + def test_game_creation(self, basic_lobby, basic_cards): + """Test that Game instances are created correctly with required fields.""" + basic_lobby.status = 'playing' + basic_lobby.save() + + game = Game.objects.create( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='in_progress' + ) + + assert game.lobby == basic_lobby + assert game.trump_card == basic_cards['ace_hearts'] + assert game.status == 'in_progress' + assert game.finished_at is None + assert game.loser is None + + def test_game_str_representation(self, basic_game): + """Test string representation shows lobby name and game status.""" + expected = "Game in Test Lobby (in_progress)" + assert str(basic_game) == expected + + def test_is_active_method(self, basic_game): + """Test is_active() returns True for in_progress games and False for finished.""" + # Active game + assert basic_game.is_active() is True + + # Finished game + basic_game.status = 'finished' + assert basic_game.is_active() is False + + def test_get_trump_suit_method(self, basic_game, card_suits): + """Test get_trump_suit() returns the suit of the trump card.""" + trump_suit = basic_game.get_trump_suit() + assert trump_suit == card_suits['hearts'] + + def test_get_player_count_method(self, basic_game, test_user, second_user): + """Test get_player_count() returns correct number of players in game.""" + # Initially no players + assert basic_game.get_player_count() == 0 + + # Add first player + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + assert basic_game.get_player_count() == 1 + + # Add second player + GamePlayer.objects.create( + game=basic_game, + user=second_user, + seat_position=2, + cards_remaining=6 + ) + assert basic_game.get_player_count() == 2 + + def test_get_winner_method_active_game(self, basic_game): + """Test get_winner() returns None for games that are still in progress.""" + assert basic_game.get_winner() is None + + def test_get_winner_method_finished_game(self, basic_game, test_user, second_user): + """Test get_winner() returns all players except the loser. + + In the game, the loser is the player left with cards when the deck + is empty. All other players are considered winners. + """ + player1 = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=0 + ) + + player2 = GamePlayer.objects.create( + game=basic_game, + user=second_user, + seat_position=2, + cards_remaining=3 + ) + + # Finish the game with player2 as loser + basic_game.status = 'finished' + basic_game.loser = second_user + basic_game.finished_at = timezone.now() + basic_game.save() + + winners = basic_game.get_winner() + + assert winners is not None + assert winners.count() == 1 + assert winners.first().user == test_user + + def test_game_ordering(self, basic_lobby, basic_cards): + """Test that games are ordered by start time (newest first). + + The model's Meta.ordering should ensure that newly created games + appear first in querysets. + """ + basic_lobby.status = 'playing' + basic_lobby.save() + + game1 = Game.objects.create( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='in_progress' + ) + + game2 = Game.objects.create( + lobby=basic_lobby, + trump_card=basic_cards['king_spades'], + status='in_progress' + ) + + games = list(Game.objects.all()) + + assert games[0] == game2 # Newest first + assert games[1] == game1 + + +@pytest.mark.django_db +class TestGamePlayerModel: + """Test suite for GamePlayer model.""" + + def test_game_player_creation(self, basic_game, test_user): + """Test that GamePlayer instances are created correctly.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + assert game_player.game == basic_game + assert game_player.user == test_user + assert game_player.seat_position == 1 + assert game_player.cards_remaining == 6 + + def test_game_player_str_representation(self, basic_game, test_user): + """Test string representation shows username, card count, and position.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + expected = "player1 (6 cards) - Position 1" + assert str(game_player) == expected + + def test_has_cards_method(self, basic_game, test_user): + """Test has_cards() returns True when player has cards remaining.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + assert game_player.has_cards() is True + + game_player.cards_remaining = 0 + assert game_player.has_cards() is False + + def test_is_eliminated_method(self, basic_game, test_user): + """Test is_eliminated() returns True when player has no cards left.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + assert game_player.is_eliminated() is False + + game_player.cards_remaining = 0 + assert game_player.is_eliminated() is True + + def test_get_hand_cards_method(self, basic_game, test_user, card_suits, card_ranks): + """Test get_hand_cards() returns all cards in player's hand.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + # Create a card in player's hand + seven = CardRank.objects.get_or_create(name="Seven", value=7)[0] + hearts = card_suits['hearts'] + card = Card.objects.create(suit=hearts, rank=seven) + + PlayerHand.objects.create( + game=basic_game, + player=test_user, + card=card, + order_in_hand=1 + ) + + hand_cards = game_player.get_hand_cards() + + assert hand_cards.count() == 1 + assert hand_cards.first().card == card + + def test_unique_together_constraint(self, basic_game, test_user): + """Test that a user cannot be added to the same game twice. + + The database enforces uniqueness on (game, user) combination. + """ + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + # Attempting to create duplicate should fail + with pytest.raises(IntegrityError): + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=2, + cards_remaining=6 + ) + + def test_game_player_ordering(self, basic_game, user_factory): + """Test that game players are ordered by seat position. + + Players should be sorted by their seat_position in ascending order. + """ + user1 = user_factory(username="player1") + user2 = user_factory(username="player2") + user3 = user_factory(username="player3") + + # Create players in non-sequential order + player2 = GamePlayer.objects.create( + game=basic_game, + user=user2, + seat_position=3, + cards_remaining=6 + ) + + player3 = GamePlayer.objects.create( + game=basic_game, + user=user3, + seat_position=2, + cards_remaining=6 + ) + + player1 = GamePlayer.objects.create( + game=basic_game, + user=user1, + seat_position=1, + cards_remaining=6 + ) + + players = list(GamePlayer.objects.filter(game=basic_game)) + + # Should be sorted by seat position + assert players[0].seat_position == 1 + assert players[1].seat_position == 2 + assert players[2].seat_position == 3 diff --git a/game/tests/test_generate_fake_games.py b/game/tests/test_generate_fake_games.py new file mode 100644 index 0000000..718bc31 --- /dev/null +++ b/game/tests/test_generate_fake_games.py @@ -0,0 +1,42 @@ +"""Tests for the `generate_fake_games` management command. + +This module verifies that the `generate_fake_games` command creates `Game` +instances and associates them with `Lobby` objects. +""" + +import pytest +from django.core.management import call_command + +from game.models import Game, Lobby + + +@pytest.mark.django_db +def test_generate_fake_games_creates_games(user_factory): + """Ensure `generate_fake_games` creates new Game objects. + + Steps: + 1. Create an owner user and a Lobby. + 2. Record the number of Game instances before running the command. + 3. Run the management command. + 4. Assert that the Game count increased and at least one game has a + non-null lobby assignment. + """ + + user = user_factory(username="owner") + lobby = Lobby.objects.create(owner=user, name="FakeLobby") + + count_before = Game.objects.count() + + # Generate fake games + call_command("generate_fake_games") + + count_after = Game.objects.count() + assert count_after > count_before, "Expected generate_fake_games to increase Game count" + + # There should be at least one game with a lobby assigned + assert Game.objects.filter(lobby__isnull=False).exists() + + # Basic sanity on the first game + game = Game.objects.first() + assert game is not None + assert game.lobby is not None diff --git a/game/tests/test_init_game_data.py b/game/tests/test_init_game_data.py new file mode 100644 index 0000000..13191e7 --- /dev/null +++ b/game/tests/test_init_game_data.py @@ -0,0 +1,36 @@ +"""Tests for the `init_game_data` management command. + +This module verifies that `init_game_data` initializes card suits, ranks, +and Card records for the requested deck size. The test runs the command +with a 36-card deck and checks that the expected numbers of suits, ranks, +and cards were created. +""" + +import pytest +from django.core.management import call_command + +from game.models import CardSuit, CardRank, Card + + +@pytest.mark.django_db +def test_init_game_data_creates_cards(): + """Run `init_game_data --deck-size 36 --reset` and verify DB state. + + The test executes the management command to initialize a 36-card deck + and asserts: + - 4 suits were created + - 9 ranks (6..10 plus J,Q,K,A) were created + - 36 Card instances were created + """ + + # Run the initialization command (reset ensures idempotence) + call_command("init_game_data", "--deck-size", "36", "--reset") + + # Check suits + assert CardSuit.objects.count() == 4 + + # Numeric 6..10 + face cards = 5 + 4 = 9 + assert CardRank.objects.count() == 9 + + # Total cards = 36 + assert Card.objects.count() == 36 diff --git a/game/tests/test_lobby_models.py b/game/tests/test_lobby_models.py new file mode 100644 index 0000000..f1b7308 --- /dev/null +++ b/game/tests/test_lobby_models.py @@ -0,0 +1,520 @@ +"""Tests for lobby-related models: Lobby, LobbySettings, and LobbyPlayer. + +This module tests lobby creation, player management, game readiness checks, +and lobby settings configuration. +""" + +import pytest +from django.db import IntegrityError +from game.models import Lobby, LobbySettings, LobbyPlayer, SpecialRuleSet + + +@pytest.mark.django_db +class TestLobbyModel: + """Test suite for Lobby model.""" + + def test_lobby_creation(self, test_user): + """Test that Lobby instances are created correctly with basic attributes.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + is_private=False, + status='waiting' + ) + + assert lobby.name == "Test Lobby" + assert lobby.owner == test_user + assert lobby.is_private is False + assert lobby.status == 'waiting' + + def test_lobby_str_representation(self, test_user): + """Test string representation returns lobby name.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Epic Game Room", + status='waiting' + ) + + assert str(lobby) == "Epic Game Room" + + def test_lobby_uuid_generation(self, test_user): + """Test that UUID is automatically generated as primary key. + + The lobby uses UUID4 for primary key which should be automatically + generated and be 36 characters long when converted to string. + """ + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + assert lobby.id is not None + assert len(str(lobby.id)) == 36 + + def test_is_full_method_empty_lobby(self, basic_lobby): + """Test is_full() returns False for empty lobby.""" + assert basic_lobby.is_full() is False + + def test_is_full_method_with_players(self, basic_lobby, user_factory): + """Test is_full() returns True when lobby reaches max_players. + + The default lobby has max_players=4, so adding 4 active players + should make is_full() return True. + """ + # Add players up to max (default is 4) + for i in range(4): + user = user_factory(username=f"player{i + 10}") + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=user, + status='waiting' + ) + + assert basic_lobby.is_full() is True + + def test_is_full_excludes_left_players(self, basic_lobby, user_factory): + """Test that is_full() doesn't count players who have left. + + Players with status 'left' should not be counted toward the + lobby's capacity, even if they haven't been removed from the database. + """ + # Add 3 active players + for i in range(3): + user = user_factory(username=f"player{i + 10}") + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=user, + status='waiting' + ) + + # Add 1 player who left + left_user = user_factory(username="left_player") + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=left_user, + status='left' + ) + + # Lobby should not be full (3 active + 1 left, max is 4) + assert basic_lobby.is_full() is False + + def test_can_start_game_method_not_enough_players(self, basic_lobby, test_user): + """Test can_start_game() returns False with insufficient ready players. + + A game requires at least 2 ready players to start. + """ + # Add only 1 ready player + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='ready' + ) + + assert basic_lobby.can_start_game() is False + + def test_can_start_game_method_enough_ready_players(self, basic_lobby, test_user, second_user): + """Test can_start_game() returns True with at least 2 ready players.""" + # Add 2 ready players + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='ready') + LobbyPlayer.objects.create(lobby=basic_lobby, user=second_user, status='ready') + + assert basic_lobby.can_start_game() is True + + def test_can_start_game_method_wrong_status(self, basic_lobby, test_user, second_user): + """Test can_start_game() returns False if lobby status is not 'waiting'. + + Only lobbies in 'waiting' status can start a game. Lobbies that are + 'playing', 'closed', or have other statuses cannot start a new game. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='ready') + LobbyPlayer.objects.create(lobby=basic_lobby, user=second_user, status='ready') + + # Change lobby status to 'playing' + basic_lobby.status = 'playing' + basic_lobby.save() + + assert basic_lobby.can_start_game() is False + + def test_get_active_players_method(self, basic_lobby, test_user, second_user, user_factory): + """Test get_active_players() returns only players who haven't left. + + Active players are those with status 'waiting', 'ready', or 'playing'. + Players with status 'left' should not be included. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='waiting') + LobbyPlayer.objects.create(lobby=basic_lobby, user=second_user, status='ready') + + left_user = user_factory(username="left") + LobbyPlayer.objects.create(lobby=basic_lobby, user=left_user, status='left') + + active_players = basic_lobby.get_active_players() + + assert active_players.count() == 2 + assert left_user not in [player.user for player in active_players] + + def test_lobby_ordering(self, test_user, second_user): + """Test that lobbies are ordered by creation date (newest first). + + The model's Meta.ordering should ensure that newly created lobbies + appear first in querysets. + """ + lobby1 = Lobby.objects.create( + owner=test_user, + name="Old Lobby", + status='waiting' + ) + lobby2 = Lobby.objects.create( + owner=second_user, + name="Newer Lobby", + status='waiting' + ) + + lobbies = list(Lobby.objects.all()) + + assert lobbies[0] == lobby2 # Newest first + assert lobbies[1] == lobby1 + + def test_private_lobby_with_password(self, test_user): + """Test creating a private lobby with password hash. + + Private lobbies should have is_private=True and can optionally + include a password_hash for authentication. + """ + private_lobby = Lobby.objects.create( + owner=test_user, + name="Private Game", + is_private=True, + password_hash="hashed_password_here", + status='waiting' + ) + + assert private_lobby.is_private is True + assert private_lobby.password_hash == "hashed_password_here" + + +@pytest.mark.django_db +class TestLobbySettingsModel: + """Test suite for LobbySettings model.""" + + def test_lobby_settings_creation(self, test_user): + """Test that LobbySettings instances are created correctly. + + Settings should be created with proper defaults and relationships + to the lobby. + """ + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False, + turn_time_limit=60 + ) + + assert settings.max_players == 4 + assert settings.card_count == 36 + assert settings.is_transferable is True + assert settings.turn_time_limit == 60 + + def test_lobby_settings_str_representation(self, test_user): + """Test string representation shows lobby name, card count, and players.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36 + ) + + expected = "Test Lobby Settings (36 cards, 4 players)" + assert str(settings) == expected + + def test_has_time_limit_method(self, test_user): + """Test has_time_limit() returns True when turn_time_limit is set.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + settings_with_limit = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + turn_time_limit=60 + ) + + assert settings_with_limit.has_time_limit() is True + + def test_has_time_limit_method_no_limit(self, test_user): + """Test has_time_limit() returns False when turn_time_limit is None.""" + lobby1 = Lobby.objects.create(owner=test_user, name="No Limit", status='waiting') + lobby2 = Lobby.objects.create(owner=test_user, name="Zero Limit", status='waiting') + + settings_no_limit = LobbySettings.objects.create( + lobby=lobby1, + max_players=2, + card_count=24, + turn_time_limit=None + ) + + settings_zero_limit = LobbySettings.objects.create( + lobby=lobby2, + max_players=2, + card_count=24, + turn_time_limit=0 + ) + + assert settings_no_limit.has_time_limit() is False + assert settings_zero_limit.has_time_limit() is False + + def test_is_beginner_friendly_method_true(self, test_user): + """Test is_beginner_friendly() returns True for simple settings. + + Beginner-friendly lobbies have: + - No transferable cards + - No jokers + - No special rule sets + - No neighbor throw restrictions + """ + lobby = Lobby.objects.create(owner=test_user, name="Beginner", status='waiting') + + beginner_settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=24, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False, + special_rule_set=None + ) + + assert beginner_settings.is_beginner_friendly() is True + + def test_is_beginner_friendly_method_false_transferable(self, test_user): + """Test is_beginner_friendly() returns False with transferable cards enabled.""" + lobby = Lobby.objects.create(owner=test_user, name="Advanced", status='waiting') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False + ) + + assert settings.is_beginner_friendly() is False + + def test_is_beginner_friendly_method_false_jokers(self, test_user): + """Test is_beginner_friendly() returns False with jokers enabled.""" + lobby = Lobby.objects.create(owner=test_user, name="Jokers", status='waiting') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=24, + is_transferable=False, + allow_jokers=True + ) + + assert settings.is_beginner_friendly() is False + + def test_is_beginner_friendly_method_false_special_rules(self, test_user): + """Test is_beginner_friendly() returns False with special rule set.""" + lobby = Lobby.objects.create(owner=test_user, name="Special", status='waiting') + + special_rules = SpecialRuleSet.objects.create( + name="Advanced Rules", + description="Complex rules", + min_players=2 + ) + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=24, + is_transferable=False, + allow_jokers=False, + special_rule_set=special_rules + ) + + assert settings.is_beginner_friendly() is False + + def test_card_count_choices(self, test_user): + """Test that valid card counts (24, 36, 52) are accepted. + + These are the standard deck sizes supported by the game: + - 24 cards: 9 through Ace in all suits + - 36 cards: 6 through Ace in all suits (most common) + - 52 cards: 2 through Ace in all suits (full deck) + """ + for count in [24, 36, 52]: + lobby = Lobby.objects.create( + owner=test_user, + name=f"Lobby{count}", + status='waiting' + ) + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=count + ) + assert settings.card_count == count + + +@pytest.mark.django_db +class TestLobbyPlayerModel: + """Test suite for LobbyPlayer model.""" + + def test_lobby_player_creation(self, basic_lobby, test_user): + """Test that LobbyPlayer instances are created correctly.""" + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + assert lobby_player.lobby == basic_lobby + assert lobby_player.user == test_user + assert lobby_player.status == 'waiting' + + def test_lobby_player_str_representation(self, basic_lobby, test_user): + """Test string representation shows username, status, and lobby name.""" + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + expected = "player1 (waiting) in Test Lobby" + assert str(lobby_player) == expected + + def test_is_active_method(self, basic_lobby, test_user): + """Test is_active() method for various player statuses. + + Active statuses: 'waiting', 'ready', 'playing' + Inactive status: 'left' + """ + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + # Test 'waiting' status + assert lobby_player.is_active() is True + + # Test 'ready' status + lobby_player.status = 'ready' + assert lobby_player.is_active() is True + + # Test 'playing' status + lobby_player.status = 'playing' + assert lobby_player.is_active() is True + + # Test 'left' status + lobby_player.status = 'left' + assert lobby_player.is_active() is False + + def test_can_start_game_method(self, basic_lobby, test_user): + """Test can_start_game() method returns True only for 'ready' status. + + Only players with 'ready' status are considered ready to start a game. + """ + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + # 'waiting' status cannot start game + assert lobby_player.can_start_game() is False + + # 'ready' status can start game + lobby_player.status = 'ready' + assert lobby_player.can_start_game() is True + + # 'playing' status cannot start game (already in game) + lobby_player.status = 'playing' + assert lobby_player.can_start_game() is False + + def test_leave_lobby_method(self, basic_lobby, test_user): + """Test leave_lobby() method updates player status to 'left'. + + When a player leaves, their status should be updated to 'left' + and persisted to the database. + """ + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + lobby_player.leave_lobby() + + lobby_player.refresh_from_db() + assert lobby_player.status == 'left' + + def test_unique_together_constraint(self, basic_lobby, test_user): + """Test that a user cannot join the same lobby twice. + + The database enforces uniqueness on (lobby, user) combination + to prevent duplicate player entries. + """ + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + # Attempting to create duplicate should fail + with pytest.raises(IntegrityError): + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + def test_lobby_player_ordering(self, basic_lobby, test_user, second_user, user_factory): + """Test that lobby players are ordered by lobby and username. + + Within a lobby, players should be sorted alphabetically by username. + """ + user3 = user_factory(username="aaa_first") + + # Create players in non-alphabetical order + player1 = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, # player1 + status='waiting' + ) + player2 = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=second_user, # player2 + status='waiting' + ) + player3 = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=user3, # aaa_first + status='waiting' + ) + + players = list(LobbyPlayer.objects.filter(lobby=basic_lobby)) + + # Should be sorted alphabetically by username + assert players[0].user.username == "aaa_first" + assert players[1].user.username == "player1" + assert players[2].user.username == "player2" diff --git a/game/tests/test_reset_games.py b/game/tests/test_reset_games.py new file mode 100644 index 0000000..98fd935 --- /dev/null +++ b/game/tests/test_reset_games.py @@ -0,0 +1,29 @@ +"""Tests for the `reset_games` management command. + +This module verifies that the `reset_games` command removes active/unfinished +Game instances when run with confirmation. +""" + +import pytest +from django.core.management import call_command + +from game.models import Game + + +@pytest.mark.django_db +def test_reset_games_removes_active_games(basic_game): + """Ensure `reset_games` deletes active games only when confirmed. + + Steps: + 1. Record number of Game instances before running the command. + 2. Run `reset_games` with no arguments + """ + + # Ensure at least one game exists (provided by the basic_game fixture) + count_before = Game.objects.count() + assert count_before >= 1, "basic_game fixture should create at least one Game" + + # Dry-run: should not delete any games + call_command("reset_games") + count_after_dry = Game.objects.count() + assert count_after_dry == count_before, "Expected dry-run reset_games to not delete games" diff --git a/game/tests/test_special_models.py b/game/tests/test_special_models.py new file mode 100644 index 0000000..c0281c6 --- /dev/null +++ b/game/tests/test_special_models.py @@ -0,0 +1,107 @@ +"""Tests for special card and rule set models. + +This module tests models responsible for custom game rules: +- SpecialCard: Defines special effects like 'skip' or 'draw'. +- SpecialRuleSet: A collection of special card rules. +- SpecialRuleSetCard: Links special cards to a rule set. +""" + +import pytest +from game.models import ( + SpecialCard, SpecialRuleSet, SpecialRuleSetCard, LobbySettings +) + + +@pytest.mark.django_db +class TestSpecialCardModel: + """Test suite for the SpecialCard model.""" + + def test_special_card_creation(self, special_card_draw): + """Tests that SpecialCard instances are created correctly.""" + assert special_card_draw.name == "Draw Two" + assert special_card_draw.effect_type == "draw" + assert special_card_draw.effect_value == {"card_count": 2} + + def test_get_effect_description(self, special_card_draw, special_card_skip): + """Tests that get_effect_description formats the description correctly.""" + draw_desc = special_card_draw.get_effect_description() + assert "2 cards" in draw_desc + + skip_desc = special_card_skip.get_effect_description() + assert skip_desc == "Next player loses their turn" + + def test_is_targetable(self, special_card_skip, special_card_reverse): + """Tests is_targetable() for different effect types.""" + assert special_card_skip.is_targetable() is True + assert special_card_reverse.is_targetable() is False + + def test_can_be_countered(self, special_card_skip): + """Tests can_be_countered() respects the default and explicit values.""" + # Default is counterable + assert special_card_skip.can_be_countered() is True + + # Explicitly set to not be counterable + uncounterable = SpecialCard.objects.create( + name="Unstoppable", + effect_type="custom", + effect_value={"counterable": False} + ) + assert uncounterable.can_be_countered() is False + + +@pytest.mark.django_db +class TestSpecialRuleSetModel: + """Test suite for the SpecialRuleSet model.""" + + def test_rule_set_creation(self, basic_rule_set): + """Tests that SpecialRuleSet instances are created correctly.""" + assert basic_rule_set.name == "Beginner Special" + assert basic_rule_set.min_players == 2 + + def test_get_special_card_count(self, basic_rule_set, special_card_skip): + """Tests get_special_card_count() returns the correct number of cards.""" + assert basic_rule_set.get_special_card_count() == 0 + SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + assert basic_rule_set.get_special_card_count() == 1 + + def test_get_enabled_special_cards( + self, basic_rule_set, special_card_skip, special_card_draw + ): + """Tests get_enabled_special_cards() returns only enabled cards.""" + # Add one enabled and one disabled card to the rule set + SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_draw, is_enabled=False + ) + + enabled_cards = basic_rule_set.get_enabled_special_cards() + assert enabled_cards.count() == 1 + assert enabled_cards.first() == special_card_skip + + +@pytest.mark.django_db +class TestSpecialRuleSetCardModel: + """Test suite for the SpecialRuleSetCard through-model.""" + + def test_association_creation(self, basic_rule_set, special_card_skip): + """Tests the creation of the association between a rule set and a card.""" + association = SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + assert association.rule_set == basic_rule_set + assert association.card == special_card_skip + assert association.is_enabled is True + + def test_toggle_enabled(self, basic_rule_set, special_card_skip): + """Tests that toggle_enabled() correctly flips the is_enabled status.""" + association = SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + association.toggle_enabled() + assert association.is_enabled is False + association.toggle_enabled() + assert association.is_enabled is True diff --git a/game/tests/test_turn_models.py b/game/tests/test_turn_models.py new file mode 100644 index 0000000..cc8c0ac --- /dev/null +++ b/game/tests/test_turn_models.py @@ -0,0 +1,114 @@ +"""Tests for turn and move tracking models. + +This module tests Turn and Move models which track game progression, +player actions, and move history. +""" + +import pytest +from django.db import IntegrityError +from game.models import Turn, Move, TableCard + + +@pytest.mark.django_db +class TestTurnModel: + """Test suite for Turn model. + + A Turn represents a single turn in the game, tracking which player's + turn it is and the turn number in sequence. + """ + + def test_turn_creation(self, basic_game, test_user): + """Tests that Turn instances are created correctly.""" + turn = Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + assert turn.game == basic_game + assert turn.player == test_user + assert turn.turn_number == 1 + + def test_get_current_turn(self, basic_game, test_user, second_user): + """Tests get_current_turn() returns the most recent turn for a game.""" + turn1 = Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + assert Turn.get_current_turn(basic_game) == turn1 + + turn2 = Turn.objects.create(game=basic_game, player=second_user, turn_number=2) + assert Turn.get_current_turn(basic_game) == turn2 + + def test_get_current_turn_no_turns(self, basic_game): + """Tests get_current_turn() returns None for a game without any turns.""" + assert Turn.get_current_turn(basic_game) is None + + def test_create_next_turn(self, basic_game, test_user, second_user): + """ + Tests create_next_turn() creates a new turn with an incremented number. + + The new turn should have a turn_number equal to the previous turn's + number plus one. + """ + Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + next_turn = Turn.create_next_turn(basic_game, second_user) + + assert next_turn.turn_number == 2 + assert next_turn.player == second_user + assert next_turn.game == basic_game + + def test_unique_together_constraint(self, basic_game, test_user, second_user): + """ + Tests that the (game, turn_number) combination is unique. + + Each game can only have one turn with a specific turn_number. + """ + Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + with pytest.raises(IntegrityError): + Turn.objects.create(game=basic_game, player=second_user, turn_number=1) + + +@pytest.mark.django_db +class TestMoveModel: + """Test suite for Move model. + + A Move represents a single action (e.g., attack, defend) during a turn. + """ + + @pytest.fixture + def attack_move(self, basic_game, test_user, basic_cards): + """Fixture for creating a sample attack move.""" + turn = Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['ace_hearts'] + ) + return Move.objects.create( + turn=turn, table_card=table_card, action_type='attack' + ) + + def test_move_creation(self, attack_move, test_user): + """Tests that Move instances are created correctly.""" + assert attack_move.turn.player == test_user + assert attack_move.action_type == 'attack' + assert attack_move.table_card is not None + + def test_get_player(self, attack_move, test_user): + """Tests get_player() returns the player from the associated turn.""" + assert attack_move.get_player() == test_user + + @pytest.mark.parametrize( + "action, method_name, expected", + [ + ("attack", "is_attack", True), + ("defend", "is_attack", False), + ("defend", "is_defense", True), + ("pickup", "is_pickup", True), + ("attack", "is_pickup", False), + ], + ) + def test_action_check_methods(self, attack_move, action, method_name, expected): + """ + Tests the boolean check methods (is_attack, is_defense, is_pickup). + + Args: + attack_move: Fixture for a sample move. + action: The action_type to set for the move. + method_name: The name of the method to call (e.g., 'is_attack'). + expected: The expected boolean result. + """ + attack_move.action_type = action + method_to_call = getattr(attack_move, method_name) + assert method_to_call() is expected diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8f0445d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = Fools_Arena.settings +python_files = tests.py test_*.py *_tests.py diff --git a/requirements.txt b/requirements.txt index e3447ed..454e5dc 100644 Binary files a/requirements.txt and b/requirements.txt differ