diff --git a/app/core/codes.py b/app/core/codes.py index 64fdd8e..0b4fadf 100644 --- a/app/core/codes.py +++ b/app/core/codes.py @@ -100,6 +100,11 @@ class ErrorCode(Enum): "PIN_IMAGE_400_3", "최대 사진 첨부 갯수를 초과했습니다.", ) + PIN_IMAGE_REQUIRED = ( + status.HTTP_400_BAD_REQUEST, + "PIN_IMAGE_400_4", + "최소 1장의 사진이 필요합니다.", + ) # File / Upload FILE_NOT_FOUND = (status.HTTP_404_NOT_FOUND, "FILE_4041", "파일을 찾을 수 없습니다.") @@ -196,6 +201,16 @@ class SuccessCode(Enum): "COMPLAINT_2003", "민원 자동 생성 스케줄러 테스트 실행에 성공했습니다.", ) + COMPLAINT_PETITION_LIST_SUCCESS = ( + status.HTTP_200_OK, + "COMPLAINT_2004", + "민원 신청 이력 목록 조회에 성공했습니다.", + ) + COMPLAINT_PETITION_GET_SUCCESS = ( + status.HTTP_200_OK, + "COMPLAINT_2005", + "민원 신청 이력 조회에 성공했습니다.", + ) FESTIVAL_FETCH_SUCCESS = ( status.HTTP_200_OK, "FESTIVAL_2001", diff --git a/app/main.py b/app/main.py index 2e9c329..3620fad 100644 --- a/app/main.py +++ b/app/main.py @@ -273,7 +273,7 @@ def custom_openapi() -> dict[str, Any]: request_model=CreateIssuePinMultipartRequest, description=( "JSON part (Content-Type: application/json). " - "photos와 pinImages 길이·순서 1:1, 이미지 1장 이상이면 isMain true 정확히 1개." + "photos와 pinImages 각 1장 이상 필수, 길이·순서 1:1, isMain true 정확히 1개." ), example={ "lat": 37.566535, @@ -289,7 +289,9 @@ def custom_openapi() -> dict[str, Any]: request_model=UpdateIssuePinMultipartRequest, description=( "JSON part (Content-Type: application/json). " - "pinImageUrls 생략+photos 없음=기존 이미지 유지, photos 있을 때 pinImages 필수." + "수정 후 최종 이미지 1장 이상 필요. " + "pinImageUrls 생략+photos 없음=기존 이미지 유지(기존 0장이면 거부), " + "pinImageUrls: []로 전부 삭제 불가, photos 있을 때 pinImages 필수." ), example={ "pinTitle": "수정된 제목", diff --git a/app/repositories/ComplaintPetitionRepo.py b/app/repositories/ComplaintPetitionRepo.py index fe3f473..0d9254b 100644 --- a/app/repositories/ComplaintPetitionRepo.py +++ b/app/repositories/ComplaintPetitionRepo.py @@ -2,11 +2,12 @@ from datetime import date -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.ComplaintPetition import ComplaintPetition +from app.models.IssuePin import IssuePin from app.models.LocationDepartment import LocationDepartment from app.models.enum.ComplaintPetitionStatus import ComplaintPetitionStatus from app.repositories.BaseRepo import BaseRepo @@ -16,6 +17,14 @@ class ComplaintPetitionRepo(BaseRepo[ComplaintPetition]): def __init__(self, session: AsyncSession) -> None: super().__init__(session, ComplaintPetition) + @staticmethod + def _review_load_options(): + return ( + selectinload(ComplaintPetition.location_department).selectinload(LocationDepartment.department), + selectinload(ComplaintPetition.location_department).selectinload(LocationDepartment.location), + selectinload(ComplaintPetition.issue_pin).selectinload(IssuePin.pin), + ) + async def exists_by_issue_pin_and_generated_on( self, *, @@ -45,14 +54,47 @@ async def get_by_petition_ids(self, petition_ids: list[int]) -> list[ComplaintPe result = await self.session.execute( select(ComplaintPetition) .where(ComplaintPetition.petition_id.in_(petition_ids)) - .options( - selectinload(ComplaintPetition.location_department).selectinload(LocationDepartment.department), - selectinload(ComplaintPetition.location_department).selectinload(LocationDepartment.location), - selectinload(ComplaintPetition.issue_pin), - ), + .options(*self._review_load_options()), ) return list(result.scalars().all()) + async def list_for_admin( + self, + *, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[ComplaintPetition], int]: + filters = [] + if status is not None: + filters.append(ComplaintPetition.status == status) + + count_stmt = select(func.count()).select_from(ComplaintPetition) + if filters: + count_stmt = count_stmt.where(*filters) + total_result = await self.session.execute(count_stmt) + total = int(total_result.scalar_one()) + + list_stmt = ( + select(ComplaintPetition) + .options(*self._review_load_options()) + .order_by(ComplaintPetition.created_at.desc()) + .limit(limit) + .offset(offset) + ) + if filters: + list_stmt = list_stmt.where(*filters) + result = await self.session.execute(list_stmt) + return list(result.scalars().all()), total + + async def get_by_petition_id_for_review(self, petition_id: int) -> ComplaintPetition | None: + result = await self.session.execute( + select(ComplaintPetition) + .where(ComplaintPetition.petition_id == petition_id) + .options(*self._review_load_options()), + ) + return result.scalar_one_or_none() + async def update_status(self, *, petition_id: int, status: str) -> bool: result = await self.session.execute( update(ComplaintPetition) diff --git a/app/routes/ComplaintApplyRoute.py b/app/routes/ComplaintApplyRoute.py index 5415d95..2546915 100644 --- a/app/routes/ComplaintApplyRoute.py +++ b/app/routes/ComplaintApplyRoute.py @@ -1,12 +1,13 @@ from __future__ import annotations -from fastapi import APIRouter, Request +from fastapi import APIRouter, Query, Request from app.core.codes import ErrorCode from app.core.codes import SuccessCode from app.core.exceptions import raise_business_exception from app.core.deps import AdminUserIdDep, ComplaintPetitionServiceDep from app.core.responses import success_response +from app.models.enum.ComplaintPetitionStatus import ComplaintPetitionStatus from app.schemas.ComplaintEmailDTO import ComplaintPetitionBulkSendRequest from app.services.internal.ComplaintPetitionSchedulerService import ComplaintPetitionSchedulerService @@ -87,3 +88,38 @@ async def run_complaint_scheduler_once( success_code=SuccessCode.COMPLAINT_SCHEDULER_RUN_SUCCESS, ) + +@router.get("/complaint-admin/petitions") +async def list_complaint_petitions_for_review( + _admin_uid: AdminUserIdDep, + complaint_petition_service: ComplaintPetitionServiceDep, + status: ComplaintPetitionStatus | None = Query( + default=None, + description="민원 상태 필터 (미지정 시 전체)", + ), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +): + result = await complaint_petition_service.list_for_review( + status=status.value if status is not None else None, + limit=limit, + offset=offset, + ) + return success_response( + result=result.model_dump(), + success_code=SuccessCode.COMPLAINT_PETITION_LIST_SUCCESS, + ) + + +@router.get("/complaint-admin/petitions/{petition_id}") +async def get_complaint_petition_for_review( + petition_id: int, + _admin_uid: AdminUserIdDep, + complaint_petition_service: ComplaintPetitionServiceDep, +): + result = await complaint_petition_service.get_for_review(petition_id) + return success_response( + result=result.model_dump(), + success_code=SuccessCode.COMPLAINT_PETITION_GET_SUCCESS, + ) + diff --git a/app/schemas/ComplaintEmailDTO.py b/app/schemas/ComplaintEmailDTO.py index 37228d6..779bed8 100644 --- a/app/schemas/ComplaintEmailDTO.py +++ b/app/schemas/ComplaintEmailDTO.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from pydantic import BaseModel, ConfigDict, Field class ComplaintEmailVlmImageSlot(BaseModel): @@ -201,6 +203,20 @@ class ComplaintPetitionApplyResponse(BaseModel): status: str +class ComplaintPetitionReviewItem(ComplaintPetitionApplyResponse): + created_at: datetime + updated_at: datetime | None = None + location_region: str | None = None + pin_title: str | None = None + + +class ComplaintPetitionReviewListResponse(BaseModel): + items: list[ComplaintPetitionReviewItem] = Field(default_factory=list) + total: int = 0 + limit: int = 0 + offset: int = 0 + + class ComplaintPetitionBulkSendRequest(BaseModel): petition_ids: list[int] = Field(default_factory=list) diff --git a/app/schemas/IssueDTO.py b/app/schemas/IssueDTO.py index 4b2d3c1..a0422b1 100644 --- a/app/schemas/IssueDTO.py +++ b/app/schemas/IssueDTO.py @@ -52,7 +52,7 @@ class CreateIssuePinMultipartRequest(BaseModel): lng: float = Field(ge=-180, le=180) pin_title: str = Field(alias="pinTitle") pin_content: str = Field(alias="pinContent") - pin_images: list[PinImageIsMainItem] = Field(alias="pinImages") + pin_images: list[PinImageIsMainItem] = Field(alias="pinImages", min_length=1) class UpdateIssuePinMultipartRequest(BaseModel): diff --git a/app/services/ComplaintPetitionService.py b/app/services/ComplaintPetitionService.py index 3c5c35b..125d593 100644 --- a/app/services/ComplaintPetitionService.py +++ b/app/services/ComplaintPetitionService.py @@ -36,6 +36,8 @@ ComplaintPetitionApplyResponse, ComplaintPetitionBulkSendItem, ComplaintPetitionBulkSendResponse, + ComplaintPetitionReviewItem, + ComplaintPetitionReviewListResponse, ) from app.schemas.IssueDTO import ImageWithLocation from app.services.ComplaintEmailService import ComplaintEmailService @@ -240,23 +242,36 @@ async def create_petition_for_issue_pin( ) await self.complaint_petition_repo.save(petition, flush_immediately=True) - return ComplaintPetitionApplyResponse( - petition_id=petition.petition_id, - issue_pin_id=issue_pin.issue_pin_id, - location_department_id=location_department.location_department_id, - location_id=location_id, - department_name=location_department.department.department_name, - location_department_email=location_department.location_department_email, - generated_on=target_generated_on.isoformat(), - pdf_s3_key=petition.pdf_s3_key, - pdf_s3_url=petition.pdf_s3_url, - email_subject=petition.email_subject, - email_body=petition.email_body, - reliability_score=petition.reliability_score, - reliability_basis=petition.reliability_basis, - status=petition.status, + petition.location_department = location_department + petition.issue_pin = issue_pin + review = self._to_review_item(petition) + return ComplaintPetitionApplyResponse.model_validate(review.model_dump()) + + async def list_for_review( + self, + *, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> ComplaintPetitionReviewListResponse: + rows, total = await self.complaint_petition_repo.list_for_admin( + status=status, + limit=limit, + offset=offset, + ) + return ComplaintPetitionReviewListResponse( + items=[self._to_review_item(row) for row in rows], + total=total, + limit=limit, + offset=offset, ) + async def get_for_review(self, petition_id: int) -> ComplaintPetitionReviewItem: + petition = await self.complaint_petition_repo.get_by_petition_id_for_review(petition_id) + if petition is None: + raise_business_exception(ErrorCode.NOT_FOUND, "민원 신청 이력을 찾지 못했습니다.") + return self._to_review_item(petition) + async def create_scheduled_petitions(self, *, default_threshold: int = _DEFAULT_TARGET_PETITION) -> dict[str, int]: generated_on = self._today_kst() offset = 0 @@ -538,6 +553,45 @@ def _build_pretty_attachment_filename(petition: ComplaintPetition) -> str: category = re.sub(r'[\\/:*?"<>|]+', "-", category).strip("-") or "category" return f"민원의견서_{date_text}_{category}_issue{petition.issue_pin_id}.pdf" + @staticmethod + def _to_review_item(petition: ComplaintPetition) -> ComplaintPetitionReviewItem: + location_department = petition.location_department + location_id = location_department.location_id if location_department is not None else 0 + department_name = "" + location_department_email = "" + location_region: str | None = None + if location_department is not None: + location_department_email = location_department.location_department_email + if location_department.department is not None: + department_name = location_department.department.department_name + if location_department.location is not None: + location_region = location_department.location.region + + pin_title: str | None = None + if petition.issue_pin is not None and petition.issue_pin.pin is not None: + pin_title = petition.issue_pin.pin.pin_title + + return ComplaintPetitionReviewItem( + petition_id=petition.petition_id, + issue_pin_id=petition.issue_pin_id, + location_department_id=petition.location_department_id, + location_id=location_id, + department_name=department_name, + location_department_email=location_department_email, + generated_on=petition.generated_on.isoformat(), + pdf_s3_key=petition.pdf_s3_key, + pdf_s3_url=petition.pdf_s3_url, + email_subject=petition.email_subject, + email_body=petition.email_body, + reliability_score=petition.reliability_score, + reliability_basis=petition.reliability_basis, + status=petition.status, + created_at=petition.created_at, + updated_at=petition.updated_at, + location_region=location_region, + pin_title=pin_title, + ) + async def _target_petition_for_location(self, *, location_id: int) -> int: if location_id <= 0: return _DEFAULT_TARGET_PETITION diff --git a/app/services/IssueService.py b/app/services/IssueService.py index 3b26a2d..27ed58f 100644 --- a/app/services/IssueService.py +++ b/app/services/IssueService.py @@ -487,6 +487,8 @@ async def _build_update_pin_image_plan( ] total_count = len(kept_specs) + len(new_specs) + if total_count < 1: + raise_business_exception(ErrorCode.PIN_IMAGE_REQUIRED) if total_count > settings.issue_pin_max_images: raise_business_exception(ErrorCode.PIN_IMAGE_COUNT_EXCEEDED) @@ -662,6 +664,8 @@ async def create_issue_pin( pin_content=request.pin_content, validation_error=ErrorCode.ISSUE_PIN_IMPORT_VALIDATION, ) + if len(uploads) < 1 or len(request.pin_images) < 1: + raise_business_exception(ErrorCode.PIN_IMAGE_REQUIRED) if len(uploads) > settings.issue_pin_max_images: raise_business_exception(ErrorCode.PIN_IMAGE_COUNT_EXCEEDED) if len(uploads) != len(request.pin_images): @@ -831,6 +835,8 @@ async def update_issue_pin( request=request, photos=uploads, ) + if image_plan.images_unchanged and not existing_images: + raise_business_exception(ErrorCode.PIN_IMAGE_REQUIRED) uploaded_keys: list[str] = [] saved_images: list[PinImage] diff --git a/tests/test_complaint_petition_review.py b/tests/test_complaint_petition_review.py new file mode 100644 index 0000000..cdd3ffd --- /dev/null +++ b/tests/test_complaint_petition_review.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +from app.core.codes import ErrorCode +from app.core.exceptions import BusinessException +from app.models.enum.ComplaintPetitionStatus import ComplaintPetitionStatus +from app.services.ComplaintPetitionService import ComplaintPetitionService + + +def _make_service( + *, + complaint_petition_repo: MagicMock | None = None, +) -> ComplaintPetitionService: + return ComplaintPetitionService( + complaint_email_service=MagicMock(), + issue_pin_repo=MagicMock(), + location_department_repo=MagicMock(), + complaint_petition_repo=complaint_petition_repo or MagicMock(), + department_repo=MagicMock(), + location_repo=MagicMock(), + user_repo=MagicMock(), + s3_util=MagicMock(), + ) + + +def _make_petition_row() -> MagicMock: + created_at = datetime(2026, 6, 16, 9, 0, tzinfo=timezone.utc) + petition = MagicMock() + petition.petition_id = 42 + petition.issue_pin_id = 7 + petition.location_department_id = 3 + petition.generated_on = date(2026, 6, 16) + petition.pdf_s3_key = "issue-pdf/petition-7.pdf" + petition.pdf_s3_url = "https://example.com/petition-7.pdf" + petition.email_subject = "[민원] 테스트 제목" + petition.email_body = "테스트 본문" + petition.reliability_score = 0.85 + petition.reliability_basis = "검증 근거" + petition.status = ComplaintPetitionStatus.CREATED.value + petition.created_at = created_at + petition.updated_at = None + + department = MagicMock() + department.department_name = "도로" + location = MagicMock() + location.region = "서울특별시 강남구" + location_department = MagicMock() + location_department.location_id = 11 + location_department.location_department_email = "road@example.com" + location_department.department = department + location_department.location = location + petition.location_department = location_department + + pin = MagicMock() + pin.pin_title = "보도블록 파손" + issue_pin = MagicMock() + issue_pin.pin = pin + petition.issue_pin = issue_pin + return petition + + +class ComplaintPetitionReviewServiceTest(unittest.IsolatedAsyncioTestCase): + def test_to_review_item_maps_joined_fields(self) -> None: + service = _make_service() + petition = _make_petition_row() + + item = service._to_review_item(petition) + + self.assertEqual(item.petition_id, 42) + self.assertEqual(item.issue_pin_id, 7) + self.assertEqual(item.location_id, 11) + self.assertEqual(item.department_name, "도로") + self.assertEqual(item.location_department_email, "road@example.com") + self.assertEqual(item.generated_on, "2026-06-16") + self.assertEqual(item.email_subject, "[민원] 테스트 제목") + self.assertEqual(item.status, ComplaintPetitionStatus.CREATED.value) + self.assertEqual(item.location_region, "서울특별시 강남구") + self.assertEqual(item.pin_title, "보도블록 파손") + + async def test_get_for_review_raises_not_found(self) -> None: + repo = MagicMock() + repo.get_by_petition_id_for_review = AsyncMock(return_value=None) + service = _make_service(complaint_petition_repo=repo) + + with self.assertRaises(BusinessException) as ctx: + await service.get_for_review(999) + + self.assertEqual(ctx.exception.error_code, ErrorCode.NOT_FOUND) + + async def test_list_for_review_returns_paginated_items(self) -> None: + petition = _make_petition_row() + repo = MagicMock() + repo.list_for_admin = AsyncMock(return_value=([petition], 1)) + service = _make_service(complaint_petition_repo=repo) + + result = await service.list_for_review(status=ComplaintPetitionStatus.CREATED.value, limit=20, offset=0) + + repo.list_for_admin.assert_awaited_once_with( + status=ComplaintPetitionStatus.CREATED.value, + limit=20, + offset=0, + ) + self.assertEqual(result.total, 1) + self.assertEqual(result.limit, 20) + self.assertEqual(result.offset, 0) + self.assertEqual(len(result.items), 1) + self.assertEqual(result.items[0].petition_id, 42) diff --git a/tests/test_issue_pin_image_required.py b/tests/test_issue_pin_image_required.py new file mode 100644 index 0000000..fce829d --- /dev/null +++ b/tests/test_issue_pin_image_required.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from app.core.codes import ErrorCode +from app.core.exceptions import BusinessException +from app.models.PinImage import PinImage +from app.schemas.IssueDTO import ( + CreateIssuePinMultipartRequest, + PinImageIsMainItem, + UpdateIssuePinMultipartRequest, +) +from app.services.IssueService import IssueService + + +def _build_issue_service() -> IssueService: + return IssueService( + vector_store_service=MagicMock(), + issue_rag_planner_service=MagicMock(), + location_resolve_client=MagicMock(), + issue_pin_llm_service=MagicMock(), + pin_repo=MagicMock(), + issue_pin_repo=MagicMock(), + pin_location_repo=MagicMock(), + pin_image_repo=MagicMock(), + pin_like_repo=MagicMock(), + community_repo=MagicMock(), + user_repo=MagicMock(), + s3_util=MagicMock(), + background_runner=MagicMock(), + issue_pin_daily_rate_limit_service=MagicMock(), + ) + + +class IssuePinImageRequiredCreateTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.service = _build_issue_service() + self.service._rate_limit.assert_daily_quota_available = AsyncMock() + + async def test_create_raises_when_no_photos(self) -> None: + request = CreateIssuePinMultipartRequest( + lat=37.566535, + lng=126.977969, + pin_title="제목", + pin_content="본문", + pin_images=[PinImageIsMainItem(is_main=True)], + ) + + with self.assertRaises(BusinessException) as ctx: + await self.service.create_issue_pin( + uid="user-1", + request=request, + photos=[], + ) + + self.assertEqual(ctx.exception.error_code, ErrorCode.PIN_IMAGE_REQUIRED) + + async def test_create_raises_when_pin_images_empty(self) -> None: + with self.assertRaises(ValueError): + CreateIssuePinMultipartRequest( + lat=37.566535, + lng=126.977969, + pin_title="제목", + pin_content="본문", + pin_images=[], + ) + + +class IssuePinImageRequiredUpdatePlanTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.service = _build_issue_service() + + async def test_update_plan_raises_when_all_images_removed(self) -> None: + existing = PinImage( + pin_image_id=1, + pin_id=10, + pin_s3_key="issueimage/a.jpg", + pin_s3_url="https://example.com/a.jpg", + is_main=True, + ) + request = UpdateIssuePinMultipartRequest( + pin_title="제목", + pin_content="본문", + pin_image_urls=[], + ) + + with self.assertRaises(BusinessException) as ctx: + await self.service._build_update_pin_image_plan( + pin_id=10, + existing_images=[existing], + request=request, + photos=[], + ) + + self.assertEqual(ctx.exception.error_code, ErrorCode.PIN_IMAGE_REQUIRED) + + async def test_update_plan_allows_unchanged_when_existing_has_images(self) -> None: + existing = PinImage( + pin_image_id=1, + pin_id=10, + pin_s3_key="issueimage/a.jpg", + pin_s3_url="https://example.com/a.jpg", + is_main=True, + ) + request = UpdateIssuePinMultipartRequest( + pin_title="제목", + pin_content="본문", + ) + + plan = await self.service._build_update_pin_image_plan( + pin_id=10, + existing_images=[existing], + request=request, + photos=[], + ) + + self.assertTrue(plan.images_unchanged) + + async def test_update_raises_when_unchanged_and_no_existing_images(self) -> None: + from datetime import datetime + from zoneinfo import ZoneInfo + + from app.models.IssuePin import IssuePin + from app.models.Pin import Pin + from app.models.PinLocation import PinLocation + from app.models.enum.IssuePinState import IssuePinState + from app.models.enum.PinType import PinType + from app.models.enum.ToneType import ToneType + + pin = Pin( + pin_id=10, + uid="user-1", + pin_type=PinType.ISSUE, + pin_title="제목", + pin_content="본문", + tone_type=ToneType.NONE, + like_count=0, + view_count=0, + created_at=datetime.now(tz=ZoneInfo("UTC")), + ) + issue_pin = IssuePin( + issue_pin_id=1, + issue_pin_state=IssuePinState.BEFORE_PROGRESS, + petition_count=0, + pin_id=10, + pin=pin, + ) + pin_location = PinLocation( + pin_id=10, + location_id=1, + detail_address="서울", + pin_point="POINT(126.977969 37.566535)", + ) + pin.pin_location = pin_location + pin.pin_images = [] + + self.service._user_repo.get_by_uid = AsyncMock(return_value=MagicMock(nickname="nick")) + self.service._issue_pin_repo.get_by_pin_id = AsyncMock(return_value=issue_pin) + self.service._rate_limit.assert_daily_quota_available = AsyncMock() + + request = UpdateIssuePinMultipartRequest( + pin_title="수정 제목", + pin_content="수정 본문", + ) + + with self.assertRaises(BusinessException) as ctx: + await self.service.update_issue_pin( + uid="user-1", + pin_id=10, + request=request, + photos=[], + ) + + self.assertEqual(ctx.exception.error_code, ErrorCode.PIN_IMAGE_REQUIRED) + + +if __name__ == "__main__": + unittest.main()