diff --git a/app/event/filters.py b/app/event/filters.py new file mode 100644 index 0000000..7a07209 --- /dev/null +++ b/app/event/filters.py @@ -0,0 +1,26 @@ +from django.db.models import Q +from django_filters import rest_framework as filters +from django_filters.constants import EMPTY_VALUES +from event.models import Event + + +class EventFilterMixin(filters.FilterSet): + event = filters.CharFilter(method="filter_by_event_name") + event_field_prefix = "event" + + def filter_by_event_name(self, queryset, name, value): + if value in EMPTY_VALUES: + return queryset + + prefix = self.event_field_prefix + return queryset.filter(Q(**{f"{prefix}__name_ko": value}) | Q(**{f"{prefix}__name_en": value})) + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + + if self.data.get("event") in EMPTY_VALUES: + latest = Event.objects.filter_active().first() + if latest: + queryset = queryset.filter(**{self.event_field_prefix: latest}) + + return queryset diff --git a/app/event/presentation/filters.py b/app/event/presentation/filters.py new file mode 100644 index 0000000..ff4c3ce --- /dev/null +++ b/app/event/presentation/filters.py @@ -0,0 +1,16 @@ +from core.models import BaseAbstractModelQuerySet +from django.db.models import Q +from django_filters import rest_framework as filters +from django_filters.constants import EMPTY_VALUES +from event.filters import EventFilterMixin + + +class PresentationFilterSet(EventFilterMixin): + event_field_prefix = "type__event" + types = filters.BaseCSVFilter(method="filter_by_type_names") + + def filter_by_type_names(self, queryset: BaseAbstractModelQuerySet, name: str, values: list[str]) -> Q: + if values in EMPTY_VALUES: + return queryset + + return queryset.filter(Q(type__name_ko__in=values) | Q(type__name_en__in=values)) diff --git a/app/event/presentation/test/api_test.py b/app/event/presentation/test/api_test.py index d397467..d88793e 100644 --- a/app/event/presentation/test/api_test.py +++ b/app/event/presentation/test/api_test.py @@ -1,5 +1,6 @@ import http import urllib.parse +from datetime import datetime import pytest from django.urls import reverse @@ -22,18 +23,20 @@ def test_presentation_api(api_client: APIClient, create_presentation_set: Presen def test_presentation_event_type_filter_api(api_client: APIClient): # Given: 행사 2개에 각각 2 종류의 발표 유형이 있고, 각 발표 유형마다 1개의 발표가 있음. organization = Organization.objects.create(name="Test Organization") - event_1: Event = Event.objects.create(organization=organization, name="Test Event 1") - event_2: Event = Event.objects.create(organization=organization, name="Test Event 2") + event_1: Event = Event.objects.create( + organization=organization, name="Test Event 1", event_start_at=datetime(2025, 8, 1) + ) + event_2: Event = Event.objects.create( + organization=organization, name="Test Event 2", event_start_at=datetime(2026, 8, 1) + ) event_1_prst_type_1 = PresentationType.objects.create(event=event_1, name="Type 1") event_1_prst_type_2 = PresentationType.objects.create(event=event_1, name="Type 2") - event_2_prst_type_1 = PresentationType.objects.create(event=event_2, name="Type 1") - event_2_prst_type_2 = PresentationType.objects.create(event=event_2, name="Type 2") + PresentationType.objects.create(event=event_2, name="Type 1") + PresentationType.objects.create(event=event_2, name="Type 2") event_1_prst_type_1_prst = Presentation.objects.create(type=event_1_prst_type_1, title="Presentation 1") event_1_prst_type_2_prst = Presentation.objects.create(type=event_1_prst_type_2, title="Presentation 2") - event_2_prst_type_1_prst = Presentation.objects.create(type=event_2_prst_type_1, title="Presentation 3") - Presentation.objects.create(type=event_2_prst_type_2, title="Presentation 4") # When: API 요청을 통해 행사 1의 발표 유형 1과 2에 해당하는 발표를 요청할 시 qs = urllib.parse.urlencode( @@ -51,16 +54,29 @@ def test_presentation_event_type_filter_api(api_client: APIClient): str(event_1_prst_type_2_prst.id), } - # When: API 요청을 통해 행사 유형은 지정하지 않고 유형 1에 해당하는 발표를 요청할 시 - qs = urllib.parse.urlencode({"types": event_1_prst_type_1.name}) - response = api_client.get(f"{reverse('v1:presentation-list')}?{qs}") - # Then: 행사 1의 발표 유형 1과 행사 2의 발표 유형 1에 해당하는 발표가 반환되어야 함. - assert response.status_code == http.HTTPStatus.OK +@pytest.mark.django_db +def test_presentation_defaults_to_latest_event(api_client: APIClient): + # Given: 2개의 행사가 있고, 각각 발표가 있음. + organization = Organization.objects.create(name="Test Organization") + old_event = Event.objects.create( + organization=organization, name="PyCon Korea 2025", event_start_at=datetime(2025, 8, 1) + ) + new_event = Event.objects.create( + organization=organization, name="PyCon Korea 2026", event_start_at=datetime(2026, 8, 1) + ) + old_type = PresentationType.objects.create(event=old_event, name="Talk") + new_type = PresentationType.objects.create(event=new_event, name="Talk") + + Presentation.objects.create(type=old_type, title="Old Presentation") + new_prst = Presentation.objects.create(type=new_type, title="New Presentation") + + # When: event 파라미터 없이 요청 + response = api_client.get(reverse("v1:presentation-list")) + + # Then: 최신 행사(2026)의 발표만 반환 + assert response.status_code == http.HTTPStatus.OK response_data = response.json() - assert len(response_data) == 2, "Should return presentations for type 1 across all events" - assert {datum["id"] for datum in response_data} == { - str(event_1_prst_type_1_prst.id), - str(event_2_prst_type_1_prst.id), - } + assert len(response_data) == 1 + assert response_data[0]["id"] == str(new_prst.id) diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index a0e5d32..c5b01ad 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,32 +1,12 @@ from core.const.tag import OpenAPITag -from core.models import BaseAbstractModelQuerySet -from django.db.models import Q from django.utils.decorators import method_decorator -from django_filters import rest_framework as filters -from django_filters.constants import EMPTY_VALUES from drf_spectacular.utils import extend_schema +from event.presentation.filters import PresentationFilterSet from event.presentation.models import Presentation, PresentationCategory from event.presentation.serializers import PresentationSerializer from rest_framework import mixins, viewsets -class PresentationFilterSet(filters.FilterSet): - event = filters.CharFilter(method="filter_by_event_name") - types = filters.BaseCSVFilter(method="filter_by_type_names") - - def filter_by_event_name(self, queryset: BaseAbstractModelQuerySet, name: str, value: str) -> Q: - if value in EMPTY_VALUES: - return queryset - - return queryset.filter(Q(type__event__name_ko=value) | Q(type__event__name_en=value)) - - def filter_by_type_names(self, queryset: BaseAbstractModelQuerySet, name: str, values: list[str]) -> Q: - if values in EMPTY_VALUES: - return queryset - - return queryset.filter(Q(type__name_ko__in=values) | Q(type__name_en__in=values)) - - @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) class PresentationCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): queryset = PresentationCategory.objects.filter_active() diff --git a/app/event/sponsor/filters.py b/app/event/sponsor/filters.py new file mode 100644 index 0000000..efb276b --- /dev/null +++ b/app/event/sponsor/filters.py @@ -0,0 +1,5 @@ +from event.filters import EventFilterMixin + + +class SponsorTierFilterSet(EventFilterMixin): + event_field_prefix = "event" diff --git a/app/event/sponsor/test/__init__.py b/app/event/sponsor/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/event/sponsor/test/api_test.py b/app/event/sponsor/test/api_test.py new file mode 100644 index 0000000..1baf037 --- /dev/null +++ b/app/event/sponsor/test/api_test.py @@ -0,0 +1,90 @@ +import http +from datetime import datetime + +import pytest +from django.urls import reverse +from event.models import Event +from event.sponsor.models import Sponsor, SponsorTier, SponsorTierSponsorRelation +from file.models import PublicFile +from rest_framework.test import APIClient +from user.models.organization import Organization + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def two_events(): + organization = Organization.objects.create(name="Test Organization") + old_event = Event.objects.create( + organization=organization, name="PyCon Korea 2025", event_start_at=datetime(2025, 8, 1) + ) + new_event = Event.objects.create( + organization=organization, name="PyCon Korea 2026", event_start_at=datetime(2026, 8, 1) + ) + return old_event, new_event + + +def _make_sponsor(event, name, tier): + logo = PublicFile.objects.create( + file=f"public/{name}.png", + mimetype="image/png", + hash=name, + size=0, + ) + sponsor = Sponsor.objects.create(event=event, name=name, logo=logo) + SponsorTierSponsorRelation.objects.create(tier=tier, sponsor=sponsor) + return sponsor + + +@pytest.mark.django_db +def test_sponsor_defaults_to_latest_event(api_client: APIClient, two_events): + old_event, new_event = two_events + + # Given: 각 행사에 후원 등급과 후원사가 있음 + old_tier = SponsorTier.objects.create(event=old_event, name="Gold", order=0) + new_tier = SponsorTier.objects.create(event=new_event, name="Gold", order=0) + + _make_sponsor(old_event, "Old Sponsor", old_tier) + _make_sponsor(new_event, "New Sponsor", new_tier) + + # When: event 파라미터 없이 요청 + response = api_client.get(reverse("v1:sponsor-list")) + + # Then: 최신 행사(2026)의 후원 등급만 반환 + assert response.status_code == http.HTTPStatus.OK + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["id"] == str(new_tier.id) + + +@pytest.mark.django_db +def test_sponsor_filter_by_event_name(api_client: APIClient, two_events): + old_event, new_event = two_events + + old_tier = SponsorTier.objects.create(event=old_event, name="Gold", order=0) + new_tier = SponsorTier.objects.create(event=new_event, name="Gold", order=0) + + _make_sponsor(old_event, "Old Sponsor", old_tier) + _make_sponsor(new_event, "New Sponsor", new_tier) + + # When: 2025 행사를 명시적으로 지정 + response = api_client.get(reverse("v1:sponsor-list"), {"event": "PyCon Korea 2025"}) + + # Then: 2025 행사의 후원 등급만 반환 + assert response.status_code == http.HTTPStatus.OK + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["id"] == str(old_tier.id) + + +@pytest.mark.django_db +def test_sponsor_no_events_returns_empty(api_client: APIClient): + # When: 이벤트가 없을 때 요청 + response = api_client.get(reverse("v1:sponsor-list")) + + # Then: 빈 응답 + assert response.status_code == http.HTTPStatus.OK + assert response.json() == [] diff --git a/app/event/sponsor/views.py b/app/event/sponsor/views.py index 25d22aa..7bd3b1e 100644 --- a/app/event/sponsor/views.py +++ b/app/event/sponsor/views.py @@ -2,6 +2,7 @@ from django.db import models from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema +from event.sponsor.filters import SponsorTierFilterSet from event.sponsor.models import Sponsor, SponsorTier from event.sponsor.serializers import SponsorTierSerializer from rest_framework import mixins, viewsets @@ -17,3 +18,4 @@ class SponsorTierViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): ) ) serializer_class = SponsorTierSerializer + filterset_class = SponsorTierFilterSet