Skip to content
15 changes: 15 additions & 0 deletions app/core/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "파일을 찾을 수 없습니다.")
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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": "수정된 제목",
Expand Down
54 changes: 48 additions & 6 deletions app/repositories/ComplaintPetitionRepo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
)
Comment on lines +20 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

동일한 관계(location_department)에 대해 여러 중첩 관계(department, location)를 로드할 때, selectinload를 각각 따로 호출하는 대신 options() 메서드를 사용하여 체이닝하는 것이 SQLAlchemy에서 더 명확하고 효율적입니다. 이렇게 하면 중복된 로더 옵션 정의를 방지할 수 있습니다.

    @staticmethod\n    def _review_load_options():\n        return (\n            selectinload(ComplaintPetition.location_department).options(\n                selectinload(LocationDepartment.department),\n                selectinload(LocationDepartment.location),\n            ),\n            selectinload(ComplaintPetition.issue_pin).selectinload(IssuePin.pin),\n        )


async def exists_by_issue_pin_and_generated_on(
self,
*,
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 37 additions & 1 deletion app/routes/ComplaintApplyRoute.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
)

16 changes: 16 additions & 0 deletions app/schemas/ComplaintEmailDTO.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field

class ComplaintEmailVlmImageSlot(BaseModel):
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion app/schemas/IssueDTO.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
68 changes: 67 additions & 1 deletion app/services/ComplaintPetitionService.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
ComplaintPetitionApplyResponse,
ComplaintPetitionBulkSendItem,
ComplaintPetitionBulkSendResponse,
ComplaintPetitionReviewItem,
ComplaintPetitionReviewListResponse,
)
from app.schemas.IssueDTO import ImageWithLocation
from app.services.ComplaintEmailService import ComplaintEmailService
Expand Down Expand Up @@ -245,7 +247,7 @@ async def create_petition_for_issue_pin(
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,
department_name=department_name,
location_department_email=location_department.location_department_email,
generated_on=target_generated_on.isoformat(),
pdf_s3_key=petition.pdf_s3_key,
Expand All @@ -257,6 +259,31 @@ async def create_petition_for_issue_pin(
status=petition.status,
)

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
Expand Down Expand Up @@ -538,6 +565,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() if petition.generated_on is not None else "",
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,
)
Comment thread
selnem marked this conversation as resolved.

async def _target_petition_for_location(self, *, location_id: int) -> int:
if location_id <= 0:
return _DEFAULT_TARGET_PETITION
Expand Down
6 changes: 6 additions & 0 deletions app/services/IssueService.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading