Skip to content
Open
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
2 changes: 2 additions & 0 deletions config/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ RUN apt-get update && apt-get install -y \
firejail \
&& rm -rf /var/lib/apt/lists/*

ENV UV_LINK_MODE copy

EXPOSE 8000
42 changes: 35 additions & 7 deletions config/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ services:
othello_postgres:
container_name: othello_postgres
image: postgres:latest
ports:
- "5432:5432"
environment:
- POSTGRES_DB=othello
- POSTGRES_USER=othello
- POSTGRES_PASSWORD=pwd
volumes:
- "othello_pgdata:/var/lib/postgresql"
- othello_pgdata:/var/lib/postgresql
ports:
- "5432:5432"
networks:
- othello_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U othello -d othello"]
interval: 5s
timeout: 5s
retries: 5
othello_redis:
container_name: othello_redis
image: redis:latest
Expand All @@ -28,8 +33,10 @@ services:
ports:
- "8000:8000"
depends_on:
- othello_postgres
- othello_redis
othello_postgres:
condition: service_healthy
othello_redis:
condition: service_started
volumes:
- ../../:/app
working_dir: /app
Expand All @@ -45,8 +52,10 @@ services:
container_name: othello_celery
image: othello_django
depends_on:
- othello_postgres
- othello_redis
othello_postgres:
condition: service_healthy
othello_redis:
condition: service_started
volumes:
- ../../:/app
working_dir: /app
Expand All @@ -58,6 +67,25 @@ services:
"-c",
"uv run celery --app othello worker -l INFO -Ofair"
]
othello_gamequeue:
container_name: othello_gamequeue
image: othello_django
depends_on:
othello_postgres:
condition: service_healthy
othello_redis:
condition: service_started
volumes:
- ../../:/app
working_dir: /app
networks:
- othello_network
entrypoint:
[
"/bin/sh",
"-c",
"uv run celery --app othello worker -l INFO -Ofair --concurrency=2 -Q game_queue"
]
volumes:
othello_pgdata:
networks:
Expand Down
16 changes: 10 additions & 6 deletions config/docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ set -e

uv sync
uv run manage.py collectstatic --noinput

until (PGPASSWORD=pwd psql -h "othello_postgres" -U "othello" -c '\q') 2> /dev/null; do
>&2 echo "waiting for postgres"
sleep 1
done

uv run manage.py makemigrations --noinput
uv run manage.py migrate
uv run manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser(
username='admin',
email='admin@admin.com',
password='123'
)
"
Comment on lines +8 to +17
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're going to do this, you should probably admin.set_password("tjcsl"), document it, and add a password login when settings.DEBUG is True

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the admin.set_password("tjcsl") part, wouldn't it be just the same as calling create_superuser? Also, why would you need to only add it on settings.DEBUG = True when it's always going to be true since its a testing environment?



exec uv run manage.py runserver 0.0.0.0:8000
34 changes: 34 additions & 0 deletions othello/apps/auth/migrations/0010_user_rating_ratinghistory.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please squash all the migrations into one big migration file

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.10 on 2026-02-02 20:22

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('authentication', '0009_alter_user_options_alter_user_id'),
('games', '0033_match_is_tie_match_loser_match_player1_wins_and_more'),
]

operations = [
migrations.AddField(
model_name='user',
name='rating',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=6),
),
migrations.CreateModel(
name='RatingHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.DecimalField(decimal_places=2, max_digits=6)),
('changed_at', models.DateTimeField(auto_now_add=True)),
('match', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='games.match')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rating_history', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['changed_at'],
},
),
]
18 changes: 18 additions & 0 deletions othello/apps/auth/migrations/0011_alter_user_rating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-02-02 23:15

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('authentication', '0010_user_rating_ratinghistory'),
]

operations = [
migrations.AlterField(
model_name='user',
name='rating',
field=models.DecimalField(decimal_places=2, default=1200.0, max_digits=6),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-02-02 23:27

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('authentication', '0011_alter_user_rating'),
]

operations = [
migrations.AddField(
model_name='user',
name='accept_ranked_matches',
field=models.BooleanField(default=True, help_text='Allow any user to request ranked matches against you'),
),
migrations.AddField(
model_name='user',
name='accept_unranked_matches',
field=models.BooleanField(default=True, help_text='Allow any user to request unranked matches against you'),
),
]
17 changes: 17 additions & 0 deletions othello/apps/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ class User(AbstractUser):
is_teacher = models.BooleanField(default=False, null=False)
is_student = models.BooleanField(default=True, null=False)
is_imported = models.BooleanField(default=False, null=False)
rating = models.DecimalField(max_digits=6, decimal_places=2, default=1200.00)
accept_ranked_matches = models.BooleanField(
default=True, help_text="Allow any user to request ranked matches against you"
)
accept_unranked_matches = models.BooleanField(
default=True, help_text="Allow any user to request unranked matches against you"
)

@property
def has_management_permission(self) -> bool:
Expand Down Expand Up @@ -35,3 +42,13 @@ def save(self, *args, **kwargs):

def __str__(self):
return self.username


class RatingHistory(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="rating_history")
rating = models.DecimalField(max_digits=6, decimal_places=2)
changed_at = models.DateTimeField(auto_now_add=True)
match = models.ForeignKey("games.Match", null=True, on_delete=models.SET_NULL)

class Meta:
ordering = ["changed_at"]
4 changes: 4 additions & 0 deletions othello/apps/auth/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@
path("", views.index, name="index"),
path("login/", views.login, name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
path("rating/", views.rating, name="rating"),
path("profile/<str:username>/", views.profile, name="profile"),
path("rankings/", views.rankings, name="rankings"),
path("update_preferences/", views.update_preferences, name="update_preferences"),
]
101 changes: 100 additions & 1 deletion othello/apps/auth/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
import json
import logging

from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.shortcuts import get_object_or_404, redirect, render

from ..games.forms import MatchForm
from ..games.models import RatingHistory, Submission

logger = logging.getLogger("othello")


def index(request: HttpRequest) -> HttpResponse:
if request.user.is_authenticated:
Comment thread
amcsz marked this conversation as resolved.
user = request.user
history = RatingHistory.objects.filter(user=user).order_by("changed_at")
Comment thread
amcsz marked this conversation as resolved.
ratings = [float(i) for i in history.values_list("rating", flat=True)]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this already be a "list" of floats?

dates = [h.changed_at.strftime("%Y-%m-%d %H:%M") for h in history]
return render(
request,
"auth/index.html",
{
"current_rating": user.rating,
"ratings_json": json.dumps(ratings),
"dates_json": json.dumps(dates),
"accept_ranked_matches": user.accept_ranked_matches,
"accept_unranked_matches": user.accept_unranked_matches,
},
)
return render(request, "auth/index.html")


Expand All @@ -12,3 +40,74 @@ def login(request: HttpRequest) -> HttpResponse:

def error(request: HttpRequest) -> HttpResponse:
return render(request, "auth/error.html")


@login_required
def rating(request: HttpRequest) -> HttpResponse:
user = request.user
history = RatingHistory.objects.filter(user=user).order_by("changed_at")
ratings = list(history.values_list("rating", flat=True))
dates = list(history.values_list("changed_at", flat=True))
return render(
request,
"auth/rating.html",
{
"current_rating": user.rating,
"ratings": ratings,
"dates": [d.isoformat() for d in dates],
},
)


@login_required
def profile(request: HttpRequest, username: str) -> HttpResponse:
profile_user = get_object_or_404(get_user_model(), username=username)

history = RatingHistory.objects.filter(user=profile_user).order_by("changed_at")

ratings = [float(i) for i in history.values_list("rating", flat=True)]
dates = [h.changed_at.strftime("%Y-%m-%d %H:%M") for h in history]

return render(
request,
"auth/profile.html",
{
"profile_user": profile_user,
"current_rating": profile_user.rating,
"ratings_json": json.dumps(ratings),
"dates_json": json.dumps(dates),
},
)


@login_required
def rankings(request: HttpRequest) -> HttpResponse:
users = get_user_model().objects.order_by("-rating").exclude(username="Yourself")
paginator = Paginator(users, 25) # Show 25 users per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
form = MatchForm(request.user)
users_with_submissions = set(
Submission.objects.values_list("user__username", flat=True).distinct()
) - {request.user.username, "Yourself"}
Comment on lines +90 to +92
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be clearer to do something like

Suggested change
users_with_submissions = set(
Submission.objects.values_list("user__username", flat=True).distinct()
) - {request.user.username, "Yourself"}
users_with_submissions = (
get_user_model()
.objects.filter(submissions__isnull=False)
.exclude(id=request.user.id)
.exclude(username="Yourself")
.distinct()
)

return render(
request,
"auth/rankings.html",
{
"page_obj": page_obj,
"form": form,
"users_with_submissions": users_with_submissions,
},
)


@login_required
def update_preferences(request: HttpRequest) -> HttpResponse:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably want django.views.decorators.http.require_POST on this view

if request.method == "POST":
accept_ranked = request.POST.get("accept_ranked_matches") == "true"
accept_unranked = request.POST.get("accept_unranked_matches") == "true"
request.user.accept_ranked_matches = accept_ranked
request.user.accept_unranked_matches = accept_unranked
Comment on lines +107 to +110
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't this in a ModelForm?

request.user.save(update_fields=["accept_ranked_matches", "accept_unranked_matches"])
messages.success(request, "Preferences updated successfully.")
return redirect("auth:index")
12 changes: 11 additions & 1 deletion othello/apps/games/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.contrib import admin

from .models import Game, GameError, Submission
from .models import Game, GameError, Match, RatingHistory, Submission


class GameErrorAdmin(admin.TabularInline):
Expand All @@ -18,5 +18,15 @@ class SubmissionAdmin(admin.ModelAdmin):
readonly_fields = ("id",)


class MatchAdmin(admin.ModelAdmin):
readonly_fields = ("id",)


class RatingHistoryAdmin(admin.ModelAdmin):
readonly_fields = ("id",)
Copy link
Copy Markdown
Member

@JasonGrace2282 JasonGrace2282 Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we customize these admins a bit more? At a minimum, it would be nice to have list_display, list_filter, and search_fields
Take a look at Tin for examples.



admin.site.register(Game, GameAdmin)
admin.site.register(Submission, SubmissionAdmin)
admin.site.register(Match, MatchAdmin)
admin.site.register(RatingHistory, RatingHistoryAdmin)
Loading