diff --git a/src/apps/dashboard/migrations/0003_alter_note_owner_alter_note_shared_with.py b/src/apps/dashboard/migrations/0003_alter_note_owner_alter_note_shared_with.py new file mode 100644 index 0000000..6cfe497 --- /dev/null +++ b/src/apps/dashboard/migrations/0003_alter_note_owner_alter_note_shared_with.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0 on 2026-03-12 19:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0002_rename_list_to_note'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='note', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='note', + name='shared_with', + field=models.ManyToManyField(blank=True, related_name='shared_notes', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/apps/epic/migrations/0001_initial.py b/src/apps/epic/migrations/0001_initial.py new file mode 100644 index 0000000..b2e760b --- /dev/null +++ b/src/apps/epic/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0 on 2026-03-12 19:46 + +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='Room', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('visibility', models.CharField(choices=[('PRIVATE', 'Private'), ('PUBLIC', 'Public'), ('INVITE ONLY', 'Invite Only')], default='PRIVATE', max_length=20)), + ('gate_status', models.CharField(choices=[('GATHERING', 'Gathering'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20)), + ('renewal_period', models.DurationField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('board_state', models.JSONField(default=dict)), + ('seed_count', models.IntegerField(default=12)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_rooms', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='GateSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slot_number', models.IntegerField()), + ('status', models.CharField(choices=[('EMPTY', 'Empty'), ('RESERVED', 'Reserved'), ('FILLED', 'Filled')], default='EMPTY', max_length=10)), + ('reserved_at', models.DateTimeField(blank=True, null=True)), + ('filled_at', models.DateTimeField(blank=True, null=True)), + ('funded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funded_slots', to=settings.AUTH_USER_MODEL)), + ('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gate_slots', to=settings.AUTH_USER_MODEL)), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gate_slots', to='epic.room')), + ], + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 71a8362..2cc90bd 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -1,3 +1,69 @@ -from django.db import models +import uuid -# Create your models here. +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings + + +class Room(models.Model): + GATHERING = "GATHERING" + OPEN = "OPEN" + RENEWAL_DUE = "RENEWAL_DUE" + GATE_STATUS_CHOICES = [ + (GATHERING, "Gathering"), + (OPEN, "Open"), + (RENEWAL_DUE, "Renewal Due"), + ] + + PRIVATE = "PRIVATE" + PUBLIC = "PUBLIC" + INVITE_ONLY = "INVITE ONLY" + VISIBILITY_CHOICES = [ + (PRIVATE, "Private"), + (PUBLIC, "Public"), + (INVITE_ONLY, "Invite Only"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=200) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_rooms" + ) + visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE) + gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING) + renewal_period = models.DurationField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + board_state = models.JSONField(default=dict) + seed_count = models.IntegerField(default=12) + +class GateSlot(models.Model): + EMPTY = "EMPTY" + RESERVED = "RESERVED" + FILLED = "FILLED" + STATUS_CHOICES = [ + (EMPTY, "Empty"), + (RESERVED, "Reserved"), + (FILLED, "Filled"), + ] + + room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="gate_slots") + slot_number = models.IntegerField() + gamer = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name="gate_slots" + ) + funded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name="funded_slots" + ) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY) + reserved_at = models.DateTimeField(null=True, blank=True) + filled_at = models.DateTimeField(null=True, blank=True) + + +@receiver(post_save, sender=Room) +def create_gate_slots(sender, instance, created, **kwargs): + if created: + for i in range(1, 7): + GateSlot.objects.create(room=instance, slot_number=i) diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py new file mode 100644 index 0000000..d200af7 --- /dev/null +++ b/src/apps/epic/tests/integrated/test_models.py @@ -0,0 +1,11 @@ +from django.test import TestCase + +from apps.lyric.models import User +from apps.epic.models import Room, GateSlot + + +class RoomCreationTest(TestCase): + def test_creating_a_room_generates_six_gate_slots(self): + owner = User.objects.create(email="founder@example.com") + room = Room.objects.create(name="Test Room", owner=owner) + self.assertEqual(GateSlot.objects.filter(room=room).count(), 6) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py new file mode 100644 index 0000000..ea24dac --- /dev/null +++ b/src/apps/epic/tests/integrated/test_views.py @@ -0,0 +1,31 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.lyric.models import User +from apps.epic.models import Room + + +class RoomCreationViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="founder@test.io") + self.client.force_login(self.user) + + def test_post_creates_room_and_redirects_to_gatekeeper(self): + response = self.client.post( + reverse("epic:create_room"), + data={"name": "Test Room"}, + ) + room = Room.objects.get(owner=self.user) + self.assertRedirects( + response, reverse( + "epic:gatekeeper", + args=[room.id], + ) + ) + + def test_post_requires_login(self): + self.client.logout() + response = self.client.post( + reverse("epic:create_room"), + data={"name": "Test Room"}, + ) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index e69de29..e05d5a3 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + + +app_name = 'epic' + +urlpatterns = [ + path('rooms/create_room', views.create_room, name='create_room'), + path('room//gate/', views.gatekeeper, name='gatekeeper'), +] + diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 91ea44a..df5a7ce 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,3 +1,22 @@ -from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render -# Create your views here. +from apps.epic.models import Room + + +@login_required +def create_room(request): + if request.method == "POST": + name = request.POST.get("name", "").strip() + if name: + room = Room.objects.create(name=name, owner=request.user) + return redirect("epic:gatekeeper", room_id=room.id) + return redirect("gameboard:index") + +def gatekeeper(request, room_id): + room = Room.objects.get(id=room_id) + slots = room.gate_slots.order_by("slot_number") + return render(request, "apps/gameboard/room.html", { + 'room': room, + 'slots': slots, + }) diff --git a/src/core/urls.py b/src/core/urls.py index 3831b91..d4ad285 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('dashboard/', include('apps.dashboard.urls')), path('lyric/', include('apps.lyric.urls')), path('gameboard/', include('apps.gameboard.urls')), + path('gameboard/', include('apps.epic.urls', 'epic')), ] # Please remove the following urlpattern diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py new file mode 100644 index 0000000..46178ea --- /dev/null +++ b/src/functional_tests/test_gatekeeper.py @@ -0,0 +1,42 @@ +from selenium.webdriver.common.by import By + +from .base import FunctionalTest +from apps.applets.models import Applet + + +class GatekeeperTest(FunctionalTest): + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="new-game", defaults={"name": "New Game", "context": "gameboard"} + ) + + def test_founder_creates_room_and_sees_gatekeeper(self): + # 1. Log in, navigate to gameboard + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + # 2. New Game applet has room name input, create button + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_applet_new_game") + ) + self.browser.find_element(By.ID, "id_new_game_name").send_keys("Test Room") + self.browser.find_element(By.ID, "id_create_game_btn").click() + # 3. User is redirected to Gatekeeper page for new room + self.wait_for( + lambda: self.assertIn("/gameboard/room/", self.browser.current_url) + ) + self.wait_for( + lambda: self.assertIn("/gate/", self.browser.current_url) + ) + # 4. Page shows room name, GATHERING status + body = self.browser.find_element(By.TAG_NAME, "body") + self.assertIn("Test Room", body.text) + self.assertIn("GATHERING", body.text) + # 5. Six token slots are visible + slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") + self.assertEqual(len(slots), 6) + # 6. Slot 1 has Drop Token btn; slots 2–6 show as empty + slot_1 = slots[0] + slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn") + for slot in slots[1:]: + self.assertIn("empty", slot.get_attribute("class")) diff --git a/src/templates/apps/gameboard/_partials/_applet-new-game.html b/src/templates/apps/gameboard/_partials/_applet-new-game.html index 297f00b..3710f93 100644 --- a/src/templates/apps/gameboard/_partials/_applet-new-game.html +++ b/src/templates/apps/gameboard/_partials/_applet-new-game.html @@ -3,7 +3,9 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

New Game

- +
+ {% csrf_token %} + + +
\ No newline at end of file diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html new file mode 100644 index 0000000..2461e96 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -0,0 +1,26 @@ +
+
+

{{ room.name }}

+ {{ room.gate_status }} +
+ +
+ {% for slot in slots %} +
+ {{ slot.slot_number }} + {% if slot.gamer %} + {{ slot.gamer.email }} + + {% else %} + empty + {% endif %} + {% if slot.slot_number == 1 and request.user == room.owner %} + + {% endif %} +
+ {% endfor %} +
+
\ No newline at end of file diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html new file mode 100644 index 0000000..34c2715 --- /dev/null +++ b/src/templates/apps/gameboard/room.html @@ -0,0 +1,16 @@ +{% extends "core/base.html" %} + +{% block content %} +
+
+ {% comment "game room content" %}gaussian blur + darkening (cf., e.g., tooltip effect) {% endcomment %} +
+
+ + {% if room.gate_status == "GATHERING" %} +
+ {% include "apps/gameboard/_partials/_gatekeeper.html" %} +
+ {% endif %} +
+{% endblock content %} \ No newline at end of file