from datetime import datetime, timedelta import pytest from django.core.exceptions import ValidationError 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_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