')}: {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
+
+
+ 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 }}
+
+{% 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
+
+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 %}
+
+
+
+
+
+ {% block content %}
+
+ {% endblock %}
+
+
+
+
+
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