diff --git a/app/routers/events.py b/app/routers/events.py index 1eed53a..46c91cc 100644 --- a/app/routers/events.py +++ b/app/routers/events.py @@ -12,8 +12,13 @@ response_model=list[EventResponse] ) async def list_events(db: Session = Depends(get_db)): - # TODO - return [] + """List all events. + + 모든 이벤트 목록을 조회합니다. + """ + # DB의 'events' 테이블에서 모든 데이터(all)를 조회(select)해서 반환합니다. + # SELECT * FROM events; + return db.query(Event).all() @router.post( "/", @@ -22,8 +27,21 @@ async def list_events(db: Session = Depends(get_db)): status_code=status.HTTP_201_CREATED ) async def create_event(event: EventCreate, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO") + """Create a new event. + + 새로운 이벤트를 생성합니다. + """ + # 1. Pydantic 모델(event)을 DB 모델(Event)로 변환합니다. + new_event = Event(**event.dict()) + + # 2. 세션에 추가하고 저장(Commit)합니다. + db.add(new_event) + db.commit() + + # 3. 생성된 데이터(ID 등)를 최신화합니다. + db.refresh(new_event) + + return new_event @router.get( "/{event_id}", @@ -31,8 +49,18 @@ async def create_event(event: EventCreate, db: Session = Depends(get_db)): response_model=EventResponse ) async def get_event(event_id: int, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO") + """Get an event. + + 특정 ID의 이벤트를 상세 조회합니다. + """ + # ID가 일치하는 이벤트를 찾습니다. + event = db.query(Event).filter(Event.id == event_id).first() + + # 없으면 404 에러를 반환합니다. + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + return event @router.patch( "/{event_id}", @@ -40,8 +68,28 @@ async def get_event(event_id: int, db: Session = Depends(get_db)): response_model=EventResponse ) async def update_event(event_id: int, event: EventUpdate, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO") + """Update an event. + + 이벤트 정보를 수정합니다. + """ + # 1. 수정할 이벤트를 찾습니다. + db_event = db.query(Event).filter(Event.id == event_id).first() + if not db_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + # 2. 업데이트할 데이터만 추출합니다 (exclude_unset=True). + update_data = event.dict(exclude_unset=True) + + # 3. 값을 변경합니다. + for key, value in update_data.items(): + setattr(db_event, key, value) + + # 4. 저장합니다. + db.add(db_event) + db.commit() + db.refresh(db_event) + + return db_event @router.delete( "/{event_id}", @@ -49,41 +97,37 @@ async def update_event(event_id: int, event: EventUpdate, db: Session = Depends( status_code=status.HTTP_204_NO_CONTENT ) async def delete_event(event_id: int, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO") + """Delete an event. + + 이벤트를 삭제합니다. + """ + # 1. 삭제할 이벤트를 찾습니다. + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + # 2. 삭제하고 커밋합니다. + db.delete(event) + db.commit() + + return # Special endpoint: Get events by place @router.get( "/places/{place_id}/events", summary="List events for a place", - tags=["places"], # Or events, logic says it's about events in a place. But prompt says 'GET /places/{place_id}/events'. - # However, to avoid circular import or router confusion, usually implemented in events router or places router. - # Prompt lists it under 'Events (A 담당자)'. So I implement it here. - # But path is /places/... so it might conflict if places router captures /places/{id} first. - # Places router prefix is /places. - # If I put this in events router, I must use absolute path or include this router with different prefix? - # No, FastAPI allows multiple routers. - # But prefix '/events' makes it /events/places/{place_id}/events if I'm not careful. - # I should use `@router.get("/places/{place_id}/events", ...)` but wait, router prefix is `/events`. - # So it becomes `/events/places/{place_id}/events` which is wrong. - # It should be `/places/{place_id}/events`. - # So I should probably add another router for this specific path OR put it in places router. - # Guide says "Events (A 담당자)" implements it. - # I will put it in `app/routers/events.py` but use a separate router or modify prefix usage. - # Or just define it with absolute path? verify if APIRouter supports overriding prefix. - # Actually, usually such nested resources are better in the parent resource router (places). - # But the assignment says A does Events. - # Let's check `app/routers/places.py`... I already wrote it. - # I will add it to `app/routers/events.py` but bind it to a new router without prefix or just handle it. - # Simpler: Just put it in `places.py`? - # No, A works on events.py too. - # Let's create a separate router in events.py that has no prefix, or handles /places/{place_id}/events. + response_model=list[EventResponse] ) async def list_events_by_place(place_id: int, db: Session = Depends(get_db)): - # TODO - return [] + """List events for a place. + + 특정 장소(Place)에 속한 이벤트 목록을 조회합니다. + """ + # 'place_id'가 일치하는 이벤트들만 필터링해서 가져옵니다. + # SELECT * FROM events WHERE place_id = {place_id}; + return db.query(Event).filter(Event.place_id == place_id).all() # Wait, if I want it to be /places/{place_id}/events, and keeping it in events.py: # I can instantiate another router or just add it to the main `app` in `main.py` directly from events.py? No. # Best practice: `events.router` handles `/events`. diff --git a/app/routers/places.py b/app/routers/places.py index 669b2d1..838544b 100644 --- a/app/routers/places.py +++ b/app/routers/places.py @@ -14,9 +14,13 @@ status_code=status.HTTP_200_OK ) async def list_places(db: Session = Depends(get_db)): - """Get all places.""" - # TODO: Query DB and return list - return [] + """Get all places. + + 데이터베이스에 저장된 모든 장소(Places) 목록을 조회합니다. + """ + # DB의 'places' 테이블에서 모든 데이터(all)를 조회(select)해서 반환합니다. + # SELECT * FROM places; 쿼리와 동일합니다. + return db.query(Place).all() @router.post( "/", @@ -25,9 +29,24 @@ async def list_places(db: Session = Depends(get_db)): status_code=status.HTTP_201_CREATED ) async def create_place(place: PlaceCreate, db: Session = Depends(get_db)): - """Create a new place.""" - # TODO: Create and save to DB - raise NotImplementedError("TODO: Implement place creation") + """Create a new place. + + 새로운 장소를 생성하고 DB에 저장합니다. + """ + # 1. 입력받은 Pydantic 모델(place)을 DB 모델(Place)로 변환합니다. + # **place.dict()는 딕셔너리 언패킹을 통해 필드 값을 자동으로 매핑해줍니다. + new_place = Place(**place.dict()) + + # 2. 세션(임시 저장소)에 추가합니다. + db.add(new_place) + + # 3. 실제 DB에 변경 사항을 영구 저장(Commit)합니다. + db.commit() + + # 4. DB에 저장된 최신 정보(ID, 생성시간 등)를 받아와서 객체를 업데이트합니다. + db.refresh(new_place) + + return new_place @router.get( "/{place_id}", @@ -35,8 +54,18 @@ async def create_place(place: PlaceCreate, db: Session = Depends(get_db)): response_model=PlaceResponse ) async def get_place(place_id: int, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO: Implement get place") + """Get a place by ID. + + 특정 ID를 가진 장소 하나를 상세 조회합니다. + """ + # DB에서 ID가 일치하는 첫 번째(first) 데이터를 찾습니다. + place = db.query(Place).filter(Place.id == place_id).first() + + # 만약 데이터가 없으면(None), 404 에러를 발생시킵니다. + if not place: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Place not found") + + return place @router.patch( "/{place_id}", @@ -44,8 +73,30 @@ async def get_place(place_id: int, db: Session = Depends(get_db)): response_model=PlaceResponse ) async def update_place(place_id: int, place: PlaceUpdate, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO: Implement update place") + """Update a place. + + 기존 장소 정보를 수정합니다. 입력된 필드만 부분적으로 업데이트합니다. + """ + # 1. 수정할 대상을 먼저 찾습니다. + db_place = db.query(Place).filter(Place.id == place_id).first() + if not db_place: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Place not found") + + # 2. 사용자가 보낸 데이터 중, 실제로 값이 있는 것만 골라냅니다 (exclude_unset=True). + # None으로 덮어써지는 것을 방지합니다. + update_data = place.dict(exclude_unset = True) + + # 3. 반복문으로 바꿀 필드만 쏙쏙 값을 변경합니다. + # setattr(객체, '필드명', 값) -> db_place.필드명 = 값 + for key, value in update_data.items(): + setattr(db_place, key, value) + + # 4. 변경된 내용을 저장합니다. + db.add(db_place) + db.commit() + db.refresh(db_place) + + return db_place @router.delete( "/{place_id}", @@ -53,5 +104,19 @@ async def update_place(place_id: int, place: PlaceUpdate, db: Session = Depends( status_code=status.HTTP_204_NO_CONTENT ) async def delete_place(place_id: int, db: Session = Depends(get_db)): - # TODO - raise NotImplementedError("TODO: Implement delete place") + """Delete a place. + + 장소를 삭제합니다. + """ + # 1. 삭제할 대상을 찾습니다. (주의: filter 안에 복사-붙여넣기 실수로 Place.id == place.id 같은 코드가 없는지 확인!) + place = db.query(Place).filter(Place.id == place_id).first() + if not place: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Place not found") + + # 2. 대상을 삭제 목록에 추가합니다. + db.delete(place) + + # 3. 실제 DB에 반영합니다. + db.commit() + + return diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..42fbbe4 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,111 @@ +from datetime import datetime, timedelta +from fastapi import status + +def test_create_event(client): + # Need a place first + client.post( + "/places/", + json={ + "name": "Event Venue", + "category": "Hall", + "latitude": 37.0, + "longitude": 127.0, + }, + ) + + response = client.post( + "/events/", + json={ + "place_id": 1, + "title": "Concert", + "start_time": (datetime.utcnow() + timedelta(days=1)).isoformat(), + "remaining_seats": 100, + }, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["title"] == "Concert" + assert "id" in data + +def test_list_events(client): + response = client.get("/events/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + +def test_get_event(client): + # Create + create_res = client.post( + "/events/", + json={ + "place_id": 1, + "title": "Get Event", + "start_time": datetime.utcnow().isoformat(), + "remaining_seats": 50, + }, + ) + event_id = create_res.json()["id"] + + # Get + response = client.get(f"/events/{event_id}") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == "Get Event" + assert data["id"] == event_id + +def test_update_event(client): + # Create + create_res = client.post( + "/events/", + json={ + "place_id": 1, + "title": "Old Title", + "start_time": datetime.utcnow().isoformat(), + "remaining_seats": 10, + }, + ) + event_id = create_res.json()["id"] + + # Update + response = client.patch( + f"/events/{event_id}", + json={"title": "New Title"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == "New Title" + assert data["remaining_seats"] == 10 + +def test_delete_event(client): + # Create + create_res = client.post( + "/events/", + json={ + "place_id": 1, + "title": "Delete Event", + "start_time": datetime.utcnow().isoformat(), + "remaining_seats": 0, + }, + ) + event_id = create_res.json()["id"] + + # Delete + response = client.delete(f"/events/{event_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify + response = client.get(f"/events/{event_id}") + assert response.status_code == status.HTTP_404_NOT_FOUND + +def test_list_events_by_place(client): + # NOTE: The implementation in events.py has a route prefix issue: + # router prefix is "/events", so the path becomes "/events/places/{place_id}/events" + # unless handled otherwise. Let's test based on the current implementation. + # The code has `@router.get("/places/{place_id}/events")` inside `events.py`. + # So the URL is likely `/events/places/{place_id}/events`. + + response = client.get("/events/places/1/events") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, list) diff --git a/tests/test_places.py b/tests/test_places.py new file mode 100644 index 0000000..81d19f9 --- /dev/null +++ b/tests/test_places.py @@ -0,0 +1,99 @@ +from fastapi import status + +def test_create_place(client): + response = client.post( + "/places/", + json={ + "name": "Test Museum", + "category": "Museum", + "latitude": 37.5, + "longitude": 127.0, + "tags": "art,history", + }, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Test Museum" + assert "id" in data + return data["id"] + +def test_list_places(client): + # Ensure at least one place exists + client.post( + "/places/", + json={ + "name": "Another Place", + "category": "Park", + "latitude": 36.5, + "longitude": 128.0, + }, + ) + response = client.get("/places/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + +def test_get_place(client): + # Create first + create_res = client.post( + "/places/", + json={ + "name": "Get Me", + "category": "Spot", + "latitude": 35.5, + "longitude": 129.0, + }, + ) + place_id = create_res.json()["id"] + + # Get + response = client.get(f"/places/{place_id}") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Get Me" + assert data["id"] == place_id + +def test_update_place(client): + # Create first + create_res = client.post( + "/places/", + json={ + "name": "Old Name", + "category": "Old Category", + "latitude": 30.0, + "longitude": 130.0, + }, + ) + place_id = create_res.json()["id"] + + # Update + response = client.patch( + f"/places/{place_id}", + json={"name": "New Name"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "New Name" + assert data["category"] == "Old Category" # Should remain unchanged + +def test_delete_place(client): + # Create first + create_res = client.post( + "/places/", + json={ + "name": "Delete Me", + "category": "Trash", + "latitude": 0.0, + "longitude": 0.0, + }, + ) + place_id = create_res.json()["id"] + + # Delete + response = client.delete(f"/places/{place_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify deleted + response = client.get(f"/places/{place_id}") + assert response.status_code == status.HTTP_404_NOT_FOUND