Skip to content

Commit 7117560

Browse files
committed
feat: timetable용 admin_api 추가
1 parent 833c91c commit 7117560

5 files changed

Lines changed: 517 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
from collections import defaultdict
5+
6+
from core.serializer.operation_serializer import Operation, OperationSerializer
7+
from core.util.dateutil import any_to_datetime
8+
from django.db import transaction
9+
from django.db.models import Count, Max
10+
from event.models import Event
11+
from event.presentation.models import Presentation, Room, RoomSchedule
12+
from rest_framework import serializers
13+
14+
15+
class RoomOperationAdminSerializer(OperationSerializer, serializers.ModelSerializer):
16+
# 같은 요청의 신규 스케줄이 이 방을 참조하기 위한 요청 스코프 토큰(저장 안 함, 부모 save 에서 실제 pk 로 remap).
17+
ref = serializers.CharField(write_only=True, required=False)
18+
19+
class Meta(OperationSerializer.Meta):
20+
model = Room
21+
fields = OperationSerializer.Meta.fields + ("ref", "name_ko", "name_en", "order")
22+
23+
def get_operation_queryset(self): # update/delete 는 이 event 하위 방으로 한정
24+
return Room.objects.filter_active().filter(event=self.context["event"])
25+
26+
27+
class RoomScheduleOperationAdminSerializer(OperationSerializer, serializers.ModelSerializer):
28+
# room_id: 읽기는 방 pk 를 그대로 노출, 쓰기는 기존 방 id 또는 같은 요청 방 create 의 ref → 부모 save 에서 해소.
29+
room_id = serializers.CharField()
30+
presentation = serializers.PrimaryKeyRelatedField(queryset=Presentation.objects.filter_active())
31+
32+
class Meta(OperationSerializer.Meta):
33+
model = RoomSchedule
34+
fields = OperationSerializer.Meta.fields + ("room_id", "start_at", "end_at", "presentation")
35+
36+
def get_operation_queryset(self): # update/delete 는 이 event 하위 스케줄로 한정
37+
return RoomSchedule.objects.filter_active().filter(room__event=self.context["event"])
38+
39+
def validate(self, attrs: dict) -> dict:
40+
start = any_to_datetime(attrs.get("start_at", getattr(self.instance, "start_at", None)))
41+
end = any_to_datetime(attrs.get("end_at", getattr(self.instance, "end_at", None)))
42+
if start and end and start >= end:
43+
raise serializers.ValidationError({"start_at": "시작 시간은 종료 시간보다 전이어야 합니다."})
44+
return attrs
45+
46+
47+
def timetable_version(event: Event) -> str:
48+
rooms = Room.objects.filter_active().filter(event_id=event.id).aggregate(c=Count("id"), m=Max("updated_at"))
49+
scheds = (
50+
RoomSchedule.objects.filter_active()
51+
.filter(room__event_id=event.id)
52+
.aggregate(c=Count("id"), m=Max("updated_at"))
53+
)
54+
raw = f"{rooms['c']}|{rooms['m']}|{scheds['c']}|{scheds['m']}"
55+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
56+
57+
58+
class TimetableAdminSerializer(serializers.Serializer):
59+
rooms = RoomOperationAdminSerializer(many=True, required=False)
60+
schedules = RoomScheduleOperationAdminSerializer(many=True, required=False)
61+
62+
def to_representation(self, event: Event) -> dict:
63+
return super().to_representation(
64+
{
65+
"rooms": Room.objects.filter_active().filter(event_id=event.id).order_by("order", "pk"),
66+
"schedules": (
67+
RoomSchedule.objects.filter_active().filter(room__event_id=event.id).order_by("start_at", "pk")
68+
),
69+
}
70+
)
71+
72+
@transaction.atomic
73+
def save(self, **kwargs) -> Event:
74+
event = self.context["event"]
75+
room_ops = self.validated_data.get("rooms", [])
76+
room_results = self.fields["rooms"].apply(room_ops, event=event)
77+
78+
ref_to_room = {
79+
op["ref"]: room
80+
for op, room in zip(room_ops, room_results)
81+
if op["op"] == Operation.CREATE and op.get("ref") is not None
82+
}
83+
rooms_by_id = {str(room.id): room for room in Room.objects.filter_active().filter(event_id=event.id)}
84+
85+
schedule_ops = self.validated_data.get("schedules", [])
86+
for attrs in schedule_ops:
87+
if not (room_key := attrs.get("room_id")):
88+
continue
89+
if not (room := ref_to_room.get(room_key) or rooms_by_id.get(room_key)):
90+
raise serializers.ValidationError({"schedules": "이벤트에 속한 활성 발표장이 아닙니다."})
91+
attrs["room_id"] = room.id
92+
self.fields["schedules"].apply(schedule_ops)
93+
94+
rows = (
95+
RoomSchedule.objects.filter_active()
96+
.filter(room__event_id=event.id)
97+
.order_by("room_id", "start_at")
98+
.values("room_id", "room__deleted_at", "start_at", "end_at")
99+
)
100+
by_room: dict = defaultdict(list)
101+
for row in rows:
102+
if row["room__deleted_at"] is not None:
103+
raise serializers.ValidationError({"schedules": "삭제된 발표장을 참조하는 세션이 남아 있습니다."})
104+
by_room[row["room_id"]].append(row)
105+
for items in by_room.values():
106+
for prev, cur in zip(items, items[1:]):
107+
if cur["start_at"] < prev["end_at"]:
108+
raise serializers.ValidationError({"schedules": "같은 발표장에 시간이 겹치는 세션이 있습니다."})
109+
110+
return event
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import http
2+
import uuid
3+
4+
import pytest
5+
from admin_api.serializers.event.timetable import timetable_version
6+
from django.urls import reverse
7+
from event.models import Event
8+
from event.presentation.models import Presentation, PresentationType, Room, RoomSchedule
9+
from rest_framework.test import APIClient
10+
from user.models.organization import Organization
11+
12+
TIMETABLE = "v1:admin-event-presentation-timetable-detail"
13+
14+
15+
@pytest.fixture
16+
def event(db) -> Event:
17+
organization = Organization.objects.create(name="Org")
18+
return Event.objects.create(name="PyCon", organization=organization)
19+
20+
21+
@pytest.fixture
22+
def presentation(event) -> Presentation:
23+
ptype = PresentationType.objects.create(name="Talk", event=event)
24+
return Presentation.objects.create(type=ptype, title="A talk")
25+
26+
27+
@pytest.fixture
28+
def room(event) -> Room:
29+
return Room.objects.create(event=event, name="Room A")
30+
31+
32+
def _url(event: Event) -> str:
33+
return reverse(TIMETABLE, args=[event.id])
34+
35+
36+
def _version(event: Event) -> str:
37+
return timetable_version(event)
38+
39+
40+
def _get(api_client: APIClient, event: Event):
41+
return api_client.get(_url(event))
42+
43+
44+
def _put(api_client: APIClient, event: Event, payload: dict, if_match: str | None = None):
45+
extra = {"HTTP_IF_MATCH": if_match} if if_match is not None else {}
46+
return api_client.put(_url(event), payload, format="json", **extra)
47+
48+
49+
# ---- Auth -------------------------------------------------------------------
50+
51+
52+
@pytest.mark.django_db
53+
def test_unauthenticated_rejected(event):
54+
response = APIClient().get(_url(event))
55+
assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED)
56+
57+
58+
@pytest.mark.django_db
59+
def test_non_superuser_rejected(event, customer_user):
60+
client = APIClient()
61+
client.force_authenticate(user=customer_user)
62+
assert client.get(_url(event)).status_code == http.HTTPStatus.FORBIDDEN
63+
64+
65+
# ---- Read -------------------------------------------------------------------
66+
67+
68+
@pytest.mark.django_db
69+
def test_get_returns_rooms_schedules_and_etag(api_client, event, room, presentation):
70+
RoomSchedule.objects.create(
71+
room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00"
72+
)
73+
response = _get(api_client, event)
74+
assert response.status_code == http.HTTPStatus.OK
75+
assert len(response.data["rooms"]) == 1
76+
assert len(response.data["schedules"]) == 1
77+
assert "version" not in response.data # 동시성 토큰은 ETag 헤더로만
78+
assert response["ETag"].strip('"') == _version(event)
79+
80+
81+
@pytest.mark.django_db
82+
def test_unknown_event_returns_404(api_client):
83+
assert api_client.get(reverse(TIMETABLE, args=[uuid.uuid4()])).status_code == http.HTTPStatus.NOT_FOUND
84+
85+
86+
@pytest.mark.django_db
87+
def test_head_returns_etag_without_body(api_client, event, room):
88+
response = api_client.head(_url(event))
89+
assert response.status_code == http.HTTPStatus.OK
90+
assert response["ETag"].strip('"') == _version(event)
91+
assert not response.content
92+
93+
94+
# ---- Save (op-based partial reflection) -------------------------------------
95+
96+
97+
@pytest.mark.django_db
98+
def test_put_creates_room_and_schedule(api_client, event, presentation):
99+
payload = {
100+
"rooms": [{"op": "create", "ref": "new-room", "name_ko": "새방", "name_en": "New", "order": 0}],
101+
"schedules": [
102+
{
103+
"op": "create",
104+
"room_id": "new-room", # 같은 요청 방 create 의 ref 로 참조 → 서버가 실제 pk 로 remap
105+
"presentation": str(presentation.id),
106+
"start_at": "2026-08-16T10:00:00+09:00",
107+
"end_at": "2026-08-16T11:00:00+09:00",
108+
}
109+
],
110+
}
111+
response = _put(api_client, event, payload, if_match=_version(event))
112+
assert response.status_code == http.HTTPStatus.OK
113+
[created_room] = response.data["rooms"]
114+
[schedule] = response.data["schedules"]
115+
assert str(schedule["room_id"]) == str(created_room["id"]) # ref → 서버 생성 id 로 해소됨
116+
117+
118+
@pytest.mark.django_db
119+
def test_put_schedule_references_existing_room_by_id(api_client, event, room, presentation):
120+
payload = {
121+
"schedules": [
122+
{
123+
"op": "create",
124+
"room_id": str(room.id), # 기존 방은 실제 id 로 참조
125+
"presentation": str(presentation.id),
126+
"start_at": "2026-08-16T10:00:00+09:00",
127+
"end_at": "2026-08-16T11:00:00+09:00",
128+
}
129+
],
130+
}
131+
response = _put(api_client, event, payload, if_match=_version(event))
132+
assert response.status_code == http.HTTPStatus.OK
133+
[schedule] = response.data["schedules"]
134+
assert str(schedule["room_id"]) == str(room.id)
135+
136+
137+
@pytest.mark.django_db
138+
def test_put_unlisted_items_are_untouched(api_client, event):
139+
keep = Room.objects.create(event=event, name="Keep")
140+
edit = Room.objects.create(event=event, name="Edit")
141+
142+
payload = {"rooms": [{"op": "update", "id": str(edit.id), "name_ko": "수정됨"}]}
143+
response = _put(api_client, event, payload, if_match=_version(event))
144+
145+
assert response.status_code == http.HTTPStatus.OK
146+
edit.refresh_from_db()
147+
assert edit.name_ko == "수정됨"
148+
assert Room.objects.filter_active().filter(id=keep.id).exists() # 목록에 없던 방은 그대로
149+
assert Room.objects.filter_active().filter(event_id=event.id).count() == 2
150+
151+
152+
@pytest.mark.django_db
153+
def test_put_deletes_room_and_its_schedule(api_client, event, room, presentation):
154+
schedule = RoomSchedule.objects.create(
155+
room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00"
156+
)
157+
payload = {
158+
"rooms": [{"op": "delete", "id": str(room.id)}],
159+
"schedules": [{"op": "delete", "id": str(schedule.id)}],
160+
}
161+
response = _put(api_client, event, payload, if_match=_version(event))
162+
assert response.status_code == http.HTTPStatus.OK
163+
assert not Room.objects.filter_active().filter(id=room.id).exists()
164+
assert not RoomSchedule.objects.filter_active().filter(id=schedule.id).exists()
165+
166+
167+
@pytest.mark.django_db
168+
def test_put_deleting_room_with_remaining_schedule_is_rejected(api_client, event, room, presentation):
169+
RoomSchedule.objects.create(
170+
room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00"
171+
)
172+
# 방만 삭제하고 세션은 남겨두면 고아 세션이 되므로 거부되어야 한다.
173+
payload = {"rooms": [{"op": "delete", "id": str(room.id)}]}
174+
response = _put(api_client, event, payload, if_match=_version(event))
175+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
176+
assert Room.objects.filter_active().filter(id=room.id).exists() # 롤백됨
177+
178+
179+
@pytest.mark.django_db
180+
def test_put_stale_if_match_returns_412(api_client, event, room):
181+
stale = _version(event)
182+
Room.objects.create(event=event, name="Concurrent") # 그 사이 버전 변경
183+
184+
response = _put(api_client, event, {"rooms": [{"op": "delete", "id": str(room.id)}]}, if_match=stale)
185+
assert response.status_code == http.HTTPStatus.PRECONDITION_FAILED
186+
assert len(response.data["rooms"]) == 2 # 현재 서버 상태를 함께 반환
187+
assert Room.objects.filter_active().filter(id=room.id).exists() # 저장 미적용
188+
189+
190+
@pytest.mark.django_db
191+
def test_put_conflict_returns_400_and_rolls_back(api_client, event, room, presentation):
192+
overlapping = {
193+
"schedules": [
194+
{
195+
"op": "create",
196+
"room_id": str(room.id),
197+
"presentation": str(presentation.id),
198+
"start_at": "2026-08-16T10:00:00+09:00",
199+
"end_at": "2026-08-16T11:00:00+09:00",
200+
},
201+
{
202+
"op": "create",
203+
"room_id": str(room.id),
204+
"presentation": str(presentation.id),
205+
"start_at": "2026-08-16T10:30:00+09:00",
206+
"end_at": "2026-08-16T11:30:00+09:00",
207+
},
208+
],
209+
}
210+
response = _put(api_client, event, overlapping, if_match=_version(event))
211+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
212+
assert RoomSchedule.objects.filter_active().filter(room__event_id=event.id).count() == 0 # 롤백됨
213+
214+
215+
@pytest.mark.django_db
216+
def test_put_update_missing_instance_returns_400(api_client, event):
217+
payload = {"rooms": [{"op": "update", "id": str(uuid.uuid4()), "name_ko": "없음"}]}
218+
response = _put(api_client, event, payload, if_match=_version(event))
219+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
220+
221+
222+
@pytest.mark.django_db
223+
def test_put_cannot_touch_other_event_room(api_client, event):
224+
# 다른 event 의 방 id 로 update 를 시도해도 이 event 스코프 밖이라 400, 대상은 그대로여야 한다.
225+
other_event = Event.objects.create(name="Other", organization=event.organization)
226+
foreign = Room.objects.create(event=other_event, name="Foreign")
227+
228+
payload = {"rooms": [{"op": "update", "id": str(foreign.id), "name_ko": "침범"}]}
229+
response = _put(api_client, event, payload, if_match=_version(event))
230+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
231+
foreign.refresh_from_db()
232+
assert foreign.name_ko == "Foreign" # 변경 안 됨
233+
234+
235+
@pytest.mark.django_db
236+
def test_put_schedule_with_unknown_room_ref_returns_400(api_client, event, presentation):
237+
payload = {
238+
"schedules": [
239+
{
240+
"op": "create",
241+
"room_id": "no-such-ref", # 존재하는 방 id 도, 이번 요청의 방 ref 도 아님
242+
"presentation": str(presentation.id),
243+
"start_at": "2026-08-16T10:00:00+09:00",
244+
"end_at": "2026-08-16T11:00:00+09:00",
245+
}
246+
],
247+
}
248+
response = _put(api_client, event, payload, if_match=_version(event))
249+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
250+
251+
252+
# ---- Version token ----------------------------------------------------------
253+
254+
255+
@pytest.mark.django_db
256+
def test_version_changes_on_delete(event):
257+
room = Room.objects.create(event=event, name="R")
258+
before = _version(event)
259+
room.delete() # soft delete (updated_at 은 안 오르지만 count 가 바뀜)
260+
assert _version(event) != before

app/admin_api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
RoomScheduleAdminViewSet,
1212
)
1313
from admin_api.views.event.sponsor import SponsorAdminViewSet, SponsorTagAdminViewSet, SponsorTierAdminViewSet
14+
from admin_api.views.event.timetable import EventPresentationTimetableAdminViewSet
1415
from admin_api.views.external_api.google_oauth2 import GoogleOAuth2AdminViewSet
1516
from admin_api.views.file import PublicFileAdminViewSet
1617
from admin_api.views.mcp_token import McpTokenAdminViewSet
@@ -72,6 +73,11 @@
7273
admin_event_router.register("presentationspeaker", PresentationSpeakerAdminViewSet)
7374
admin_event_router.register("room", RoomAdminViewSet)
7475
admin_event_router.register("roomschedule", RoomScheduleAdminViewSet)
76+
admin_event_router.register(
77+
"presentation/timetable",
78+
EventPresentationTimetableAdminViewSet,
79+
basename="admin-event-presentation-timetable",
80+
)
7581

7682
admin_participant_portal_router = routers.SimpleRouter()
7783
admin_participant_portal_router.register(

0 commit comments

Comments
 (0)