from datetime import datetime, timedelta import pytest from django.core.exceptions import ValidationError from django.db import connection from django.test.utils import CaptureQueriesContext from django.utils import timezone from events.admin import EventDateColumn, OrganizersColumn from events.models import ( EventCategory, EventOccurrence, EventOrganizer, EventOrganizerLink, EventPage, ) from tests.conftest import ( AssociationPageFactory, CustomImageFactory, EventPageFactory, ) def test_eventpage_clean_unsets_specific_pricing_when_free(): page = EventPage( title="Free event", slug="free-event", free=True, price_regular="100", price_student="50", price_member="25", ) page.clean() assert page.price_regular == "" assert page.price_student == "" assert page.price_member == "" def test_eventpage_clean_keeps_specific_pricing_when_not_free(): page = EventPage( title="Paid event", slug="paid-event", free=False, price_regular="100", price_student="50", price_member="25", ) page.clean() assert page.price_regular == "100" assert page.price_student == "50" assert page.price_member == "25" def test_eventpage_clean_dedupes_organizers_by_name(event_index): org_a = EventOrganizer.objects.create(name="DNS", slug="dns-a") org_b = EventOrganizer.objects.create(name="DNS", slug="dns-b") event = EventPageFactory(parent=event_index) EventOrganizerLink.objects.create(event=event, organizer=org_a) EventOrganizerLink.objects.create(event=event, organizer=org_b) event = EventPage.objects.get(pk=event.pk) assert event.organizer_links.count() == 2 event.clean() assert event.organizer_links.count() == 1 def test_eventpage_clean_dedupes_three_duplicates_and_keeps_distinct(event_index): dup_1 = EventOrganizer.objects.create(name="DNS", slug="dns-1") dup_2 = EventOrganizer.objects.create(name="DNS", slug="dns-2") dup_3 = EventOrganizer.objects.create(name="DNS", slug="dns-3") distinct = EventOrganizer.objects.create(name="Studentersamfundet", slug="ss") event = EventPageFactory(parent=event_index) for organizer in (dup_1, dup_2, dup_3, distinct): EventOrganizerLink.objects.create(event=event, organizer=organizer) event = EventPage.objects.get(pk=event.pk) assert event.organizer_links.count() == 4 event.clean() names = sorted(link.organizer.name for link in event.organizer_links.all()) assert names == ["DNS", "Studentersamfundet"] def test_eventoccurrence_clean_rejects_both_venue_and_venue_custom(event_index, venue): event = EventPageFactory(parent=event_index) occurrence = EventOccurrence( event=event, start=timezone.now(), venue=venue, venue_custom="Frederikkeplassen", ) with pytest.raises(ValidationError) as exc: occurrence.clean() assert "venue_custom" in exc.value.message_dict def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index): event = EventPageFactory(parent=event_index) occurrence = EventOccurrence(event=event, start=timezone.now()) with pytest.raises(ValidationError) as exc: occurrence.clean() assert "venue" in exc.value.message_dict def test_eventpage_manager_future_filters_past_and_annotates(event_index): now = timezone.now() past = EventPageFactory(parent=event_index, title="Past") EventOccurrence.objects.create(event=past, start=now - timedelta(days=7), venue_custom="Old") future = EventPageFactory(parent=event_index, title="Future") EventOccurrence.objects.create(event=future, start=now + timedelta(days=7), venue_custom="New") results = list(EventPage.objects.live().future().order_by("next_occurrence")) assert [p.pk for p in results] == [future.pk] assert results[0].next_occurrence is not None def test_future_includes_occurrence_late_today(event_index): today_start = timezone.localtime(timezone.now()).replace( hour=0, minute=0, second=0, microsecond=0 ) late_today = today_start + timedelta(hours=23, minutes=59) event = EventPageFactory(parent=event_index, title="Late today") EventOccurrence.objects.create(event=event, start=late_today, venue_custom="X") assert event.pk in EventPage.objects.future().values_list("pk", flat=True) def test_future_excludes_occurrence_just_before_today(event_index): today_start = timezone.localtime(timezone.now()).replace( hour=0, minute=0, second=0, microsecond=0 ) just_before_today = today_start - timedelta(seconds=1) event = EventPageFactory(parent=event_index, title="Just past") EventOccurrence.objects.create(event=event, start=just_before_today, venue_custom="X") assert event.pk not in EventPage.objects.future().values_list("pk", flat=True) def test_future_next_occurrence_picks_earliest_future_ignoring_past(event_index): now = timezone.now() soonest_future = now + timedelta(days=3) event = EventPageFactory(parent=event_index, title="With history") EventOccurrence.objects.create(event=event, start=now - timedelta(days=30), venue_custom="X") EventOccurrence.objects.create(event=event, start=soonest_future, venue_custom="X") EventOccurrence.objects.create(event=event, start=now + timedelta(days=10), venue_custom="X") annotated = EventPage.objects.future().filter(pk=event.pk).first() assert annotated is not None assert abs((annotated.next_occurrence - soonest_future).total_seconds()) < 1 def test_graphql_event_index_future_events_query(event_index, graphql_post): upcoming = EventPageFactory(parent=event_index, title="Upcoming gig") EventOccurrence.objects.create( event=upcoming, start=timezone.now() + timedelta(days=3), venue_custom="Storsalen", ) response, body = graphql_post( """ query { eventIndex { futureEvents { title } } } """ ) assert response.status_code == 200 assert "errors" not in body, body titles = [e["title"] for e in body["data"]["eventIndex"]["futureEvents"]] assert "Upcoming gig" in titles def test_future_events_does_not_have_n_plus_one_queries( event_index, venue, association_index, graphql_post ): """Regression test: query count for futureEvents stays bounded as events grow.""" konsert = EventCategory.objects.create(name="Konsert", slug="konsert") association = AssociationPageFactory(parent=association_index, title="DNS") org = EventOrganizer.objects.create(name="Forening", slug="forening", association=association) image = CustomImageFactory(title="Cover") now = timezone.now() for i in range(5): event = EventPageFactory( parent=event_index, title=f"Event {i}", body=[("paragraph", "
x
")], featured_image=image, ) event.categories.add(konsert) EventOrganizerLink.objects.create(event=event, organizer=org) EventOccurrence.objects.create( event=event, start=now + timedelta(days=i + 1), venue=venue, ) home_query = """ query { eventIndex { futureEvents { id title subtitle body { blockType } featuredImage { url } occurrences { start end venueCustom venue { title } } categories { name slug } organizers { name slug association { title } } } } } """ with CaptureQueriesContext(connection) as ctx: response, body = graphql_post(home_query) assert response.status_code == 200 assert "errors" not in body, body assert len(body["data"]["eventIndex"]["futureEvents"]) == 5 # Bump only alongside an intentional resolver change. max_queries = 6 assert len(ctx) <= max_queries, ( f"futureEvents took {len(ctx)} queries for 5 events — likely N+1. " f"Captured queries:\n" + "\n".join(f" {i + 1}. {q['sql'][:120]}" for i, q in enumerate(ctx.captured_queries)) ) def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post): now = timezone.now() later = EventPageFactory(parent=event_index, title="Later gig") EventOccurrence.objects.create(event=later, start=now + timedelta(days=10), venue_custom="X") sooner = EventPageFactory(parent=event_index, title="Sooner gig") EventOccurrence.objects.create(event=sooner, start=now + timedelta(days=3), venue_custom="X") response, body = graphql_post( """ query { eventIndex { futureEvents { title } } } """ ) assert response.status_code == 200 assert "errors" not in body, body titles = [e["title"] for e in body["data"]["eventIndex"]["futureEvents"]] assert titles.index("Sooner gig") < titles.index("Later gig") def test_event_date_column_no_occurrences(event_index): event = EventPageFactory(parent=event_index) column = EventDateColumn("event_date") assert column.get_value(event) == "—" def test_event_date_column_single_occurrence(event_index): event = EventPageFactory(parent=event_index) start = timezone.make_aware(datetime(2025, 7, 22, 19, 30)) EventOccurrence.objects.create(event=event, start=start, venue_custom="X") column = EventDateColumn("event_date") assert column.get_value(event) == "2025-07-22 kl 19:30" def test_event_date_column_multiple_occurrences_shows_count(event_index): event = EventPageFactory(parent=event_index) now = timezone.now() EventOccurrence.objects.create(event=event, start=now, venue_custom="X") EventOccurrence.objects.create(event=event, start=now + timedelta(days=1), venue_custom="X") EventOccurrence.objects.create(event=event, start=now + timedelta(days=2), venue_custom="X") column = EventDateColumn("event_date") assert column.get_value(event) == "3 forekomster" def test_organizers_column_no_organizers(event_index): event = EventPageFactory(parent=event_index) column = OrganizersColumn("organizers") assert column.get_value(event) == "—" def test_organizers_column_single_organizer_shows_name(event_index): org = EventOrganizer.objects.create(name="Forening A", slug="forening-a") event = EventPageFactory(parent=event_index) EventOrganizerLink.objects.create(event=event, organizer=org) column = OrganizersColumn("organizers") assert column.get_value(event) == "Forening A" def test_organizers_column_multiple_organizers_truncates_with_count(event_index): org_a = EventOrganizer.objects.create(name="Forening A", slug="forening-a") org_b = EventOrganizer.objects.create(name="Forening B", slug="forening-b") org_c = EventOrganizer.objects.create(name="Forening C", slug="forening-c") event = EventPageFactory(parent=event_index) EventOrganizerLink.objects.create(event=event, organizer=org_a, sort_order=0) EventOrganizerLink.objects.create(event=event, organizer=org_b, sort_order=1) EventOrganizerLink.objects.create(event=event, organizer=org_c, sort_order=2) column = OrganizersColumn("organizers") assert column.get_value(event) == "Forening A (+2)" @pytest.fixture def comprehensive_event(event_index, venue, association_index): """A fully-populated paid EventPage exercising every field exposed via GraphQL.""" image = CustomImageFactory( title="Cover", alt="Et fotografi av en gris med solbriller", attribution="Foto: Test", ) konsert = EventCategory.objects.create( name="Konsert", slug="konsert", show_in_filters=True, pig="pigHeadLogo" ) klubb = EventCategory.objects.create(name="Klubb", slug="klubb") association = AssociationPageFactory( parent=association_index, title="Internal", association_type="forening", ) internal_org = EventOrganizer.objects.create( name="Internal", slug="internal", association=association ) external_org = EventOrganizer.objects.create( name="External", slug="external", external_url="https://external.example.com", ) event = EventPageFactory( parent=event_index, title="Et arrangement", slug="et-arrangement", subtitle="En undertekst", lead="Ingress.
", body=[("paragraph", "Body content.
")], pig="automatic", free=False, price_regular="150", price_student="100", price_member="75", ticket_url="https://example.com/tickets", facebook_url="https://facebook.com/example", featured_image=image, ) event.categories.add(konsert, klubb) EventOrganizerLink.objects.create(event=event, organizer=internal_org) EventOrganizerLink.objects.create(event=event, organizer=external_org) now = timezone.now() EventOccurrence.objects.create( event=event, start=now + timedelta(days=5), end=now + timedelta(days=5, hours=3), venue=venue, ) EventOccurrence.objects.create( event=event, start=now + timedelta(days=12), end=now + timedelta(days=12, hours=2), venue_custom="Frederikkeplassen", ) event.save() return event def test_graphql_event_index_returns_all_fields_for_comprehensive_event( comprehensive_event, graphql_post ): response, body = graphql_post( """ query { eventIndex { futureEvents { title slug subtitle lead body { blockType field ... on RichTextBlock { value } } pig free priceRegular priceStudent priceMember ticketUrl facebookUrl featuredImage { alt attribution } categories { name slug showInFilters pig } organizers { name slug externalUrl association { title } } occurrences { start end venueCustom venue { title } } } } } """ ) assert response.status_code == 200 assert "errors" not in body, body events = body["data"]["eventIndex"]["futureEvents"] event = next(e for e in events if e["title"] == "Et arrangement") assert event["slug"] == "et-arrangement" assert event["subtitle"] == "En undertekst" assert "Ingress." in event["lead"] assert event["pig"] == "automatic" assert event["free"] is False assert event["priceRegular"] == "150" assert event["priceStudent"] == "100" assert event["priceMember"] == "75" assert event["ticketUrl"] == "https://example.com/tickets" assert event["facebookUrl"] == "https://facebook.com/example" assert event["featuredImage"]["alt"] == "Et fotografi av en gris med solbriller" assert event["featuredImage"]["attribution"] == "Foto: Test" assert event["body"][0]["blockType"] == "RichTextBlock" assert "Body content." in event["body"][0]["value"] categories_by_name = {c["name"]: c for c in event["categories"]} assert set(categories_by_name) == {"Konsert", "Klubb"} assert categories_by_name["Konsert"]["slug"] == "konsert" assert categories_by_name["Konsert"]["showInFilters"] is True assert categories_by_name["Konsert"]["pig"] == "pigHeadLogo" assert categories_by_name["Klubb"]["showInFilters"] is False organizers_by_name = {o["name"]: o for o in event["organizers"]} assert set(organizers_by_name) == {"Internal", "External"} assert organizers_by_name["Internal"]["association"]["title"] == "Internal" assert organizers_by_name["Internal"]["externalUrl"] == "" assert organizers_by_name["External"]["association"] is None assert organizers_by_name["External"]["externalUrl"] == "https://external.example.com" assert len(event["occurrences"]) == 2 venue_occ = next(o for o in event["occurrences"] if o["venue"] is not None) custom_occ = next(o for o in event["occurrences"] if o["venueCustom"]) assert venue_occ["venueCustom"] == "" assert venue_occ["venue"]["title"] assert custom_occ["venue"] is None assert custom_occ["venueCustom"] == "Frederikkeplassen" venue_occ_db = comprehensive_event.occurrences.exclude(venue=None).get() custom_occ_db = comprehensive_event.occurrences.exclude(venue_custom="").get() assert datetime.fromisoformat(venue_occ["start"]) == venue_occ_db.start assert datetime.fromisoformat(venue_occ["end"]) == venue_occ_db.end assert datetime.fromisoformat(custom_occ["start"]) == custom_occ_db.start assert datetime.fromisoformat(custom_occ["end"]) == custom_occ_db.end