diff --git a/polylan_submitter/clock/consumers.py b/polylan_submitter/clock/consumers.py index 5ca2ec4..71d81b8 100644 --- a/polylan_submitter/clock/consumers.py +++ b/polylan_submitter/clock/consumers.py @@ -16,7 +16,9 @@ from .wire import encode, to_epoch_ms class ClockConsumer(AsyncWebsocketConsumer): async def connect(self): self.event_path = self.scope["url_route"]["kwargs"]["event_path"] - self.event = await self._get_enabled_event(self.event_path) + self.event = await self._get_enabled_event( + self.event_path, self.scope.get("user") + ) if self.event is None: await self.close() return @@ -62,8 +64,8 @@ class ClockConsumer(AsyncWebsocketConsumer): ) @database_sync_to_async - def _get_enabled_event(self, event_path): - event = Event.objects.filter(path=event_path).first() + def _get_enabled_event(self, event_path, user): + event = Event.objects.available_to(user).filter(path=event_path).first() if event is None: return None if not Game.objects.enabled_for_event(event).filter(path="clock").exists(): diff --git a/polylan_submitter/events/admin.py b/polylan_submitter/events/admin.py index b0710c3..563c842 100644 --- a/polylan_submitter/events/admin.py +++ b/polylan_submitter/events/admin.py @@ -5,7 +5,9 @@ from .models import Event @admin.register(Event) class EventAdmin(admin.ModelAdmin): - list_display = ["name", "path", "created_at"] + list_display = ["name", "path", "is_available", "created_at"] + list_editable = ["is_available"] + list_filter = ["is_available"] search_fields = ["name", "path"] prepopulated_fields = {"path": ("name",)} readonly_fields = ["created_at", "updated_at"] diff --git a/polylan_submitter/events/middleware.py b/polylan_submitter/events/middleware.py index a67f220..0e9e4fb 100644 --- a/polylan_submitter/events/middleware.py +++ b/polylan_submitter/events/middleware.py @@ -22,6 +22,9 @@ class EventContextMiddleware: def process_view(self, request, view_func, view_args, view_kwargs): event_path = view_kwargs.get("event_path") if event_path: - request.event = get_object_or_404(Event, path=event_path) + request.event = get_object_or_404( + Event.objects.available_to(getattr(request, "user", None)), + path=event_path, + ) current_event.set(request.event) return None diff --git a/polylan_submitter/events/migrations/0003_event_is_available.py b/polylan_submitter/events/migrations/0003_event_is_available.py new file mode 100644 index 0000000..2d37e4e --- /dev/null +++ b/polylan_submitter/events/migrations/0003_event_is_available.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-10-15 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0002_seed_polylan_43"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="is_available", + field=models.BooleanField(default=True), + ), + ] diff --git a/polylan_submitter/events/models.py b/polylan_submitter/events/models.py index c89b7e4..08fbd7c 100644 --- a/polylan_submitter/events/models.py +++ b/polylan_submitter/events/models.py @@ -14,9 +14,21 @@ class BaseModel(models.Model): abstract = True +class EventQuerySet(models.QuerySet): + def available_to(self, user): + """Unavailable events are hidden from everyone but staff, so they can + still be prepared before being opened up.""" + if user is not None and user.is_staff: + return self + return self.filter(is_available=True) + + class Event(BaseModel): name = models.CharField(max_length=255) path = models.SlugField(max_length=100, unique=True) + is_available = models.BooleanField(default=True) + + objects = EventQuerySet.as_manager() def __str__(self) -> str: return self.name diff --git a/polylan_submitter/events/tests.py b/polylan_submitter/events/tests.py index ae0f2d1..73d0ab0 100644 --- a/polylan_submitter/events/tests.py +++ b/polylan_submitter/events/tests.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from django.core.exceptions import ImproperlyConfigured from django.test import TestCase @@ -32,3 +34,41 @@ class EventScopedManagerTests(TestCase): current_event.reset(token) self.assertEqual(results, [self.submission_a]) + + +class EventAvailabilityTests(TestCase): + def setUp(self): + from accounts.models import CustomUser + + self.hidden = Event.objects.create( + name="Hidden", path="hidden", is_available=False + ) + self.user = CustomUser.objects.create_user(username="user", password="x") + self.staff = CustomUser.objects.create_user( + username="staff", password="x", is_staff=True + ) + + def test_unavailable_event_is_404_for_regular_user(self): + self.client.force_login(self.user) + response = self.client.get(f"/{self.hidden.path}/market") + self.assertEqual(response.status_code, 404) + + def test_unavailable_event_is_visible_to_staff(self): + self.client.force_login(self.staff) + response = self.client.get(f"/{self.hidden.path}/market") + self.assertEqual(response.status_code, 200) + + def test_available_to_filters_for_regular_user_only(self): + self.assertNotIn(self.hidden, Event.objects.available_to(self.user)) + self.assertIn(self.hidden, Event.objects.available_to(self.staff)) + + def test_home_prefers_available_event_for_staff(self): + available = Event.objects.create(name="Open", path="open") + Event.objects.filter(pk=self.hidden.pk).update( + created_at=available.created_at + timedelta(days=1) + ) + self.client.force_login(self.staff) + response = self.client.get("/") + self.assertRedirects( + response, f"/{available.path}/", fetch_redirect_response=False + ) diff --git a/polylan_submitter/place/consumers.py b/polylan_submitter/place/consumers.py index f8c003f..e3f9a33 100644 --- a/polylan_submitter/place/consumers.py +++ b/polylan_submitter/place/consumers.py @@ -16,7 +16,9 @@ from .wire import encode, to_epoch_ms class PlaceConsumer(AsyncWebsocketConsumer): async def connect(self): self.event_path = self.scope["url_route"]["kwargs"]["event_path"] - self.event = await self._get_enabled_event(self.event_path) + self.event = await self._get_enabled_event( + self.event_path, self.scope.get("user") + ) if self.event is None: await self.close() return @@ -80,8 +82,8 @@ class PlaceConsumer(AsyncWebsocketConsumer): ) @database_sync_to_async - def _get_enabled_event(self, event_path): - event = Event.objects.filter(path=event_path).first() + def _get_enabled_event(self, event_path, user): + event = Event.objects.available_to(user).filter(path=event_path).first() if event is None: return None if not Game.objects.enabled_for_event(event).filter(path="place").exists(): diff --git a/polylan_submitter/polylan_submitter/urls.py b/polylan_submitter/polylan_submitter/urls.py index 7f290cb..14616ad 100644 --- a/polylan_submitter/polylan_submitter/urls.py +++ b/polylan_submitter/polylan_submitter/urls.py @@ -36,7 +36,9 @@ OPUS_MAGNUM_APP_ID = 558990 @login_required def home(request: HttpRequest): - event = Event.objects.order_by("-created_at").first() + # Staff land on an available event too; hidden ones are only a fallback. + events = Event.objects.available_to(request.user) + event = events.order_by("-is_available", "-created_at").first() if event is None: raise Http404("No event configured") return redirect("event.home", event_path=event.path)