Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ FROM python:3.11-slim

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends graphviz \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt
Expand Down
6 changes: 2 additions & 4 deletions Fools_Arena/routing.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from channels.routing import URLRouter
from channels.auth import AuthMiddlewareStack

from chat.routing import websocket_urlpatterns as chat_routes
from game.routing import websocket_urlpatterns as game_routes

websocket_application = AuthMiddlewareStack(
URLRouter(chat_routes + game_routes)
)
# AuthMiddlewareStack is applied once in asgi.py (do not wrap here too).
websocket_application = URLRouter(chat_routes + game_routes)
10 changes: 10 additions & 0 deletions Fools_Arena/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_extensions",
'rest_framework',
'accounts',
'chat',
Expand Down Expand Up @@ -149,3 +150,12 @@
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
13 changes: 13 additions & 0 deletions Fools_Arena/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Django settings for automated tests (no Redis dependency).

Extends production settings but swaps the Channels layer for an in-memory
backend so ``pytest`` does not require a running Redis instance.
"""

from Fools_Arena.settings import * # noqa: F401,F403 pylint:disable=wildcard-import,unused-wildcard-import

CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
3 changes: 2 additions & 1 deletion Fools_Arena/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@
# UI
path("accounts/", include("accounts.urls")),
path("chat/", include("chat.urls")),
path("game/", include("game.urls")),
# API
path("api/accounts/", include("accounts.api_urls")),
path("api/chat/", include("chat.api_urls")),

path("api/game/", include("game.api_urls")),
]

# Add static files
Expand Down
7 changes: 5 additions & 2 deletions accounts/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Fools Arena{% endblock %}</title>
{% block extra_head %}{% endblock %}
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
<header>
<h1>Fools Arena</h1>
<nav>
<a href="{% url 'login' %}">Login</a> |
<a href="{% url 'register' %}">Register</a> |
<a href="{% url 'profile' %}">Profile</a> |
<a href="{% url 'game-lobby-list-page' %}">Games</a> |
<a href="{% url 'chat:inbox' %}">Chat</a>
</nav>
</header>
Expand All @@ -22,7 +25,7 @@ <h1>Fools Arena</h1>
</main>

<footer>
<p>&copy; 2025 Fools Arena</p>
<p>&copy; 2026 Fools Arena</p>
</footer>
</body>
</html>
5 changes: 5 additions & 0 deletions chat/consumers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser

from .models import Chat, ChatParticipant, Message
from .services import assert_can_send_message
Expand All @@ -16,6 +17,10 @@ async def connect(self):
self.user = self.scope["user"]
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]

if isinstance(self.user, AnonymousUser):
await self.close(code=4401)
return

# Check if user has access to the chat
if not await self.user_can_access_chat():
await self.close(code=403) # Forbidden
Expand Down
52 changes: 52 additions & 0 deletions chat/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
and validation before messages are stored or broadcast.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from accounts.models import Block

if TYPE_CHECKING:
from game.models import Lobby


def is_direct_message_blocked(sender, recipient):
"""Return True if ``recipient`` has blocked ``sender`` (sender cannot DM).
Expand All @@ -22,6 +29,51 @@ def is_direct_message_blocked(sender, recipient):
return Block.objects.filter(blocker=recipient, blocked=sender).exists()


def get_lobby_chat(lobby: Lobby):
"""Return the lobby-linked :class:`~chat.models.Chat`, syncing active players.

Creates ``is_lobby=True`` chat on first use and ensures every non-left
:class:`~game.models.LobbyPlayer` is a :class:`~chat.models.ChatParticipant`.

Args:
lobby: :class:`~game.models.Lobby` instance.

Returns:
Chat: The canonical lobby chat for this lobby.
"""
from game.models import LobbyPlayer

from .models import Chat

chat = (
Chat.objects.filter(lobby=lobby, is_lobby=True).order_by("created_at").first()
)
if chat is None:
chat = Chat.objects.create(
lobby=lobby,
is_lobby=True,
is_group=True,
name=(lobby.name or "Lobby chat")[:100],
)
for lp in LobbyPlayer.objects.filter(lobby=lobby).exclude(status="left"):
chat.add_participant(lp.user)
return chat


def remove_user_from_lobby_chat(lobby: Lobby, user):
"""Drop ``user`` from the lobby chat when they leave the lobby table.

Args:
lobby: Lobby the user left.
user: Participant to remove from the linked chat (if any).
"""
from .models import Chat

chat = Chat.objects.filter(lobby=lobby, is_lobby=True).first()
if chat:
chat.remove_participant(user)


def assert_can_send_message(chat, sender):
"""Raise ``PermissionError`` if ``sender`` may not post in ``chat``.

Expand Down
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_basic_game_has_trump(basic_game, basic_cards):
import os
import pytest

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.test_settings")

import django

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ services:
db:
image: postgres:17
restart: always
ports:
- "5433:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
Expand Down
25 changes: 25 additions & 0 deletions game/api_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""URL routes for the JSON API under ``/api/game/``."""

from django.urls import path

from game import api_views

urlpatterns = [
path("lobbies/", api_views.LobbyListCreateAPI.as_view(), name="api-lobby-list"),
path("lobbies/<uuid:lobby_id>/", api_views.LobbyDetailAPI.as_view(), name="api-lobby-detail"),
path("lobbies/<uuid:lobby_id>/join/", api_views.LobbyJoinAPI.as_view(), name="api-lobby-join"),
path("lobbies/<uuid:lobby_id>/leave/", api_views.LobbyLeaveAPI.as_view(), name="api-lobby-leave"),
path("lobbies/<uuid:lobby_id>/ready/", api_views.LobbyReadyAPI.as_view(), name="api-lobby-ready"),
path("lobbies/<uuid:lobby_id>/start/", api_views.LobbyStartAPI.as_view(), name="api-lobby-start"),
path(
"lobbies/<uuid:lobby_id>/messages/",
api_views.LobbyMessagesAPI.as_view(),
name="api-lobby-messages",
),
path("games/<uuid:game_id>/", api_views.GameStateAPI.as_view(), name="api-game-state"),
path("games/<uuid:game_id>/attack/", api_views.GameAttackAPI.as_view(), name="api-game-attack"),
path("games/<uuid:game_id>/seal/", api_views.GameSealAPI.as_view(), name="api-game-seal"),
path("games/<uuid:game_id>/defend/", api_views.GameDefendAPI.as_view(), name="api-game-defend"),
path("games/<uuid:game_id>/take/", api_views.GameTakeAPI.as_view(), name="api-game-take"),
path("games/<uuid:game_id>/bito/", api_views.GameBitoAPI.as_view(), name="api-game-bito"),
]
Loading
Loading