Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
Comment on lines +23 to +24

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

๋™์ผํ•œ ๊ด€๊ณ„ ๊ฒฝ๋กœ(ComplaintPetition.location_department)์— ๋Œ€ํ•ด ์—ฌ๋Ÿฌ ๋ฒˆ selectinload๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋Œ€์‹ , options() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•˜์œ„ ๊ด€๊ณ„๋“ค์„ ํ•œ ๋ฒˆ์— ์ฒด์ด๋‹ํ•˜๋Š” ๊ฒƒ์ด ๋” ๊น”๋”ํ•˜๊ณ  SQLAlchemy์—์„œ ๊ถŒ์žฅํ•˜๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.

            selectinload(ComplaintPetition.location_department).options(
                selectinload(LocationDepartment.department),
                selectinload(LocationDepartment.location),
            ),

selectinload(ComplaintPetition.issue_pin).selectinload(IssuePin.pin),
)

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)
Comment on lines +78 to +86

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

SQLAlchemy์—์„œ where ํ•„ํ„ฐ๋ฅผ limit ๋ฐ offset ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ์ดํ›„์— ์ ์šฉํ•˜๋Š” ๊ฒƒ์€ ๋ฌธ๋ฒ•์ ์œผ๋กœ๋Š” ํ—ˆ์šฉ๋˜์ง€๋งŒ, ์‹ค์ œ ์ƒ์„ฑ๋˜๋Š” SQL์˜ ๊ตฌ์กฐ์™€ ์ผ์น˜ํ•˜์ง€ ์•Š์•„ ๊ฐ€๋…์„ฑ์„ ๋–จ์–ด๋œจ๋ฆฌ๊ณ  ํ˜ผ๋ž€์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•„ํ„ฐ(where)๋ฅผ ๋จผ์ € ์ ์šฉํ•œ ํ›„ ์ •๋ ฌ(order_by) ๋ฐ ํŽ˜์ด์ง•(limit, offset)์„ ์ ์šฉํ•˜๋Š” ๊ฒƒ์ด ๋” ์ง๊ด€์ ์ด๊ณ  ์˜ฌ๋ฐ”๋ฅธ ๊ด€๋ก€์ž…๋‹ˆ๋‹ค.

        list_stmt = select(ComplaintPetition).options(*self._review_load_options())
        if filters:
            list_stmt = list_stmt.where(*filters)
        list_stmt = (
            list_stmt.order_by(ComplaintPetition.created_at.desc())
            .limit(limit)
            .offset(offset)
        )

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
84 changes: 69 additions & 15 deletions 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 @@ -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
Expand Down Expand Up @@ -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
Comment on lines +563 to +566

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_email ๋˜๋Š” department_name์ด None์ผ ๊ฒฝ์šฐ, ComplaintPetitionReviewItem ๋ฐ ComplaintPetitionApplyResponse ์Šคํ‚ค๋งˆ์—์„œ ํ•ด๋‹น ํ•„๋“œ๋“ค์„ ํ•„์ˆ˜ ๋ฌธ์ž์—ด(str)๋กœ ์ •์˜ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— Pydantic ๊ฒ€์ฆ ์—๋Ÿฌ(ValidationError)๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•ˆ์ „ํ•œ ๋ฐฉ์–ด์  ํ”„๋กœ๊ทธ๋ž˜๋ฐ์„ ์œ„ํ•ด or ""๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ None์ธ ๊ฒฝ์šฐ ๋นˆ ๋ฌธ์ž์—ด๋กœ ๋Œ€์ฒดํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

Suggested change
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 is not None:
location_department_email = location_department.location_department_email or ""
if location_department.department is not None:
department_name = location_department.department.department_name or ""

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
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