massive additions made thru somewhat new apps.epic.models, .urls, .views; new html page & partial in apps/gameboard; new apps.epic FT & ITs (all green); New Game applet now actually leads to game room feat. token-drop gatekeeper mechanism intended for 6 gamers
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
45
src/apps/epic/migrations/0001_initial.py
Normal file
45
src/apps/epic/migrations/0001_initial.py
Normal file
@@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
11
src/apps/epic/tests/integrated/test_models.py
Normal file
11
src/apps/epic/tests/integrated/test_models.py
Normal file
@@ -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)
|
||||||
31
src/apps/epic/tests/integrated/test_views.py
Normal file
31
src/apps/epic/tests/integrated/test_views.py
Normal file
@@ -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"},
|
||||||
|
)
|
||||||
@@ -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/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
|||||||
path('dashboard/', include('apps.dashboard.urls')),
|
path('dashboard/', include('apps.dashboard.urls')),
|
||||||
path('lyric/', include('apps.lyric.urls')),
|
path('lyric/', include('apps.lyric.urls')),
|
||||||
path('gameboard/', include('apps.gameboard.urls')),
|
path('gameboard/', include('apps.gameboard.urls')),
|
||||||
|
path('gameboard/', include('apps.epic.urls', 'epic')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Please remove the following urlpattern
|
# Please remove the following urlpattern
|
||||||
|
|||||||
42
src/functional_tests/test_gatekeeper.py
Normal file
42
src/functional_tests/test_gatekeeper.py
Normal file
@@ -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"))
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2>New Game</h2>
|
<h2>New Game</h2>
|
||||||
<ul class="game-type">
|
<form method="POST" action="{% url "epic:create_room" %}">
|
||||||
<small>[feature forthcoming]</small>
|
{% csrf_token %}
|
||||||
</ul>
|
<input id="id_new_game_name" name="name" type="text" placeholder="Room name" />
|
||||||
|
<button type="submit" id="id_create_game_btn" class="btn btn-primary btn-xl">Start<br>Game</button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
26
src/templates/apps/gameboard/_partials/_gatekeeper.html
Normal file
26
src/templates/apps/gameboard/_partials/_gatekeeper.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<div class="gate-modal" role"dialog" aria-label="Gatekeeper">
|
||||||
|
<header class="gate-header">
|
||||||
|
<h1>{{ room.name }}</h1>
|
||||||
|
<span class="gate-status">{{ room.gate_status }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="gate-slots">
|
||||||
|
{% for slot in slots %}
|
||||||
|
<div
|
||||||
|
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% endif %}"
|
||||||
|
data-slot="{{ slot.slot_number }}"
|
||||||
|
>
|
||||||
|
<span class="slot-number">{{ slot.slot_number }}</span>
|
||||||
|
{% if slot.gamer %}
|
||||||
|
<span class="slot-gamer">{{ slot.gamer.email }}</span>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<span class="slot-gamer">empty</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if slot.slot_number == 1 and request.user == room.owner %}
|
||||||
|
<button class="drop-token-btn">Drop Token</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
16
src/templates/apps/gameboard/room.html
Normal file
16
src/templates/apps/gameboard/room.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="room-page">
|
||||||
|
<div class="room-shell">
|
||||||
|
{% comment "game room content" %}gaussian blur + darkening (cf., e.g., tooltip effect) {% endcomment %}
|
||||||
|
<div class="room-table"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if room.gate_status == "GATHERING" %}
|
||||||
|
<div class="gate-overlay">
|
||||||
|
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
Reference in New Issue
Block a user