813 lines
28 KiB
Python
813 lines
28 KiB
Python
import json
|
|
from typing import Annotated, cast
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import FileResponse, Response
|
|
from sqlalchemy.orm import Session
|
|
from starlette.datastructures import UploadFile as StarletteUploadFile
|
|
from urllib.parse import quote
|
|
|
|
from app.core.auth import SsoPrincipal, get_principal
|
|
from app.db.session import get_db
|
|
from app.services.access_service import AccessService
|
|
from app.schemas.ticket import (
|
|
SupportTicketCommentRecord,
|
|
SupportTicketRecord,
|
|
TicketCommentCreateRequest,
|
|
TicketIssueLinkStatusSyncRequest,
|
|
TicketAssigneeCandidate,
|
|
TicketAssigneeUpdateRequest,
|
|
InternalMemoCreateRequest,
|
|
InternalMemoUpdateRequest,
|
|
TicketCommentUpdateRequest,
|
|
TicketCreateRequest,
|
|
TicketCreateResponse,
|
|
SupportFeedbackMetrics,
|
|
SupportFeedbackStatusRecord,
|
|
FeedbackStatusUpdateRequest,
|
|
TicketUpdateRequest,
|
|
TicketMutationResponse,
|
|
TicketDeleteResponse,
|
|
WorkspaceFormTemplateResponse,
|
|
WorkspaceSummary,
|
|
)
|
|
from app.services.ticket_service import TicketService
|
|
from app.services.workspace_mapping_service import workspace_mapping_service
|
|
|
|
router = APIRouter(prefix="/workspaces", tags=["tickets"])
|
|
service = TicketService()
|
|
access_service = AccessService()
|
|
MAX_ATTACHMENT_SIZE_BYTES = 30 * 1024 * 1024
|
|
|
|
|
|
def can_manage_ticket_comments(
|
|
db: Session,
|
|
principal: SsoPrincipal,
|
|
ticket_id: int,
|
|
workspace_code: str | None,
|
|
abc_feedback_id: int | None,
|
|
) -> bool:
|
|
ticket = service._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
if ticket is None:
|
|
return False
|
|
|
|
access = access_service.assert_workspace_access(
|
|
db,
|
|
principal,
|
|
ticket.workspace.workspace_code,
|
|
)
|
|
return access.can_manage or access.workspace_role in {
|
|
"SYSTEM_ADMIN",
|
|
"SUPER_ADMIN",
|
|
"PROJECT_MANAGER",
|
|
}
|
|
|
|
|
|
def require_ticket_access(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SsoPrincipal:
|
|
ticket = service._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abc_feedback_id=abcFeedbackId,
|
|
)
|
|
if ticket is None:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
access = access_service.assert_workspace_access(
|
|
db,
|
|
principal,
|
|
ticket.workspace.workspace_code,
|
|
)
|
|
if (
|
|
access.can_manage
|
|
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
|
):
|
|
return principal
|
|
if (
|
|
ticket.requester_id != principal.user_id
|
|
or ticket.requester_tenant_id != principal.tenant_id
|
|
):
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=403, detail="이 지원 요청을 조회할 권한이 없습니다.")
|
|
return principal
|
|
|
|
|
|
def require_ticket_write_access(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SsoPrincipal:
|
|
ticket = service._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abc_feedback_id=abcFeedbackId,
|
|
)
|
|
if ticket is None:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
access = access_service.assert_workspace_access(
|
|
db,
|
|
principal,
|
|
ticket.workspace.workspace_code,
|
|
write=True,
|
|
)
|
|
if (
|
|
access.can_manage
|
|
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
|
):
|
|
return principal
|
|
if (
|
|
ticket.requester_id != principal.user_id
|
|
or ticket.requester_tenant_id != principal.tenant_id
|
|
):
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=403, detail="이 지원 요청을 변경할 권한이 없습니다.")
|
|
return principal
|
|
|
|
|
|
def require_ticket_manager_access(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SsoPrincipal:
|
|
ticket = service._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abc_feedback_id=abcFeedbackId,
|
|
)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
access = access_service.assert_workspace_access(
|
|
db,
|
|
principal,
|
|
ticket.workspace.workspace_code,
|
|
)
|
|
if not (
|
|
access.can_manage
|
|
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
|
):
|
|
raise HTTPException(status_code=403, detail="프로젝트 관리자 권한이 필요합니다.")
|
|
return principal
|
|
|
|
|
|
@router.get("", response_model=list[WorkspaceSummary])
|
|
def list_workspaces(
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> list[WorkspaceSummary]:
|
|
workspace_mapping_service.sync_project_workspaces(db)
|
|
return [
|
|
WorkspaceSummary(
|
|
workspace_code=workspace.workspace_code,
|
|
workspace_name=workspace.workspace_name,
|
|
workspace_type=workspace.workspace_type,
|
|
)
|
|
for workspace, access in access_service.list_accessible_workspaces(db, principal)
|
|
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
|
]
|
|
|
|
|
|
@router.get("/{workspace_code}/form-template", response_model=WorkspaceFormTemplateResponse)
|
|
def get_workspace_form_template(
|
|
workspace_code: str,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> WorkspaceFormTemplateResponse:
|
|
access_service.assert_workspace_access(db, principal, workspace_code)
|
|
return service.get_workspace_form_template(db, workspace_code)
|
|
|
|
|
|
@router.post(
|
|
"/{workspace_code}/tickets",
|
|
response_model=TicketCreateResponse,
|
|
response_description="생성된 지원 요청의 식별자와 동기화 상태",
|
|
summary="지원 요청 등록",
|
|
description=(
|
|
"application/json 또는 multipart/form-data로 지원 요청을 등록합니다. "
|
|
"multipart 요청은 title, description, category_code, ticket_type, is_secret, "
|
|
"requires_approval, extra_fields(JSON 문자열), attachments(반복 파일 필드)를 지원합니다. "
|
|
"작성자 식별자와 SSO 프로필은 인증 사용자 정보로 서버가 설정합니다."
|
|
),
|
|
openapi_extra={
|
|
"requestBody": {
|
|
"required": True,
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {"$ref": "#/components/schemas/TicketCreateRequest"},
|
|
},
|
|
"multipart/form-data": {
|
|
"schema": {
|
|
"type": "object",
|
|
"required": ["title", "description"],
|
|
"properties": {
|
|
"title": {"type": "string", "minLength": 1, "maxLength": 255},
|
|
"description": {"type": "string", "minLength": 1},
|
|
"category_code": {"type": "string"},
|
|
"ticket_type": {"type": "string", "default": "GENERAL"},
|
|
"is_secret": {"type": "boolean", "default": False},
|
|
"requires_approval": {"type": "boolean", "default": False},
|
|
"extra_fields": {
|
|
"type": "string",
|
|
"description": "동적 필드 JSON 객체를 문자열로 전달",
|
|
},
|
|
"attachments": {
|
|
"type": "array",
|
|
"items": {"type": "string", "format": "binary"},
|
|
"description": "반복 가능한 첨부파일 필드",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
async def create_ticket(
|
|
workspace_code: str,
|
|
request: Request,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> TicketCreateResponse:
|
|
access_service.assert_workspace_access(db, principal, workspace_code, write=True)
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
if content_type.startswith("multipart/form-data"):
|
|
form = await request.form(
|
|
max_files=10,
|
|
max_part_size=MAX_ATTACHMENT_SIZE_BYTES,
|
|
)
|
|
extra_fields_value = form.get("extra_fields")
|
|
extra_fields = (
|
|
json.loads(extra_fields_value)
|
|
if isinstance(extra_fields_value, str) and extra_fields_value
|
|
else {}
|
|
)
|
|
attachments = [
|
|
cast(UploadFile, value)
|
|
for key, value in form.multi_items()
|
|
if key == "attachments"
|
|
and isinstance(value, (UploadFile, StarletteUploadFile))
|
|
]
|
|
request_payload = TicketCreateRequest(
|
|
workspace_code=workspace_code,
|
|
# Multipart writers do not send requester identity fields. Use
|
|
# the verified SSO principal before Pydantic validates the
|
|
# required fields; applying model_copy afterwards is too late
|
|
# because empty values fail min_length validation first.
|
|
requester_id=principal.user_id,
|
|
requester_tenant_id=principal.tenant_id,
|
|
requester_contact=str(form.get("requester_contact") or ""),
|
|
title=str(form.get("title") or ""),
|
|
description=str(form.get("description") or ""),
|
|
category_code=str(form.get("category_code") or ""),
|
|
ticket_type=str(form.get("ticket_type") or "GENERAL"),
|
|
is_secret=str(form.get("is_secret") or "false").lower() == "true",
|
|
requires_approval=str(form.get("requires_approval") or "false").lower() == "true",
|
|
extra_fields=extra_fields,
|
|
)
|
|
request_payload = request_payload.model_copy(update={
|
|
"requester_id": principal.user_id,
|
|
"requester_tenant_id": principal.tenant_id,
|
|
"requester_email": principal.email,
|
|
"requester_name": principal.name,
|
|
"requester_department": principal.department,
|
|
"requester_phone_number": principal.phone_number,
|
|
})
|
|
return service.create_ticket(db, request_payload, attachments)
|
|
|
|
payload = TicketCreateRequest.model_validate(await request.json())
|
|
request_payload = payload.model_copy(update={
|
|
"workspace_code": workspace_code,
|
|
"requester_id": principal.user_id,
|
|
"requester_tenant_id": principal.tenant_id,
|
|
"requester_email": principal.email,
|
|
"requester_name": principal.name,
|
|
"requester_department": principal.department,
|
|
"requester_phone_number": principal.phone_number,
|
|
})
|
|
return service.create_ticket(db, request_payload)
|
|
|
|
|
|
@router.get("/{workspace_code}/tickets", response_model=list[SupportTicketRecord])
|
|
def list_workspace_tickets(
|
|
workspace_code: str,
|
|
requesterId: str | None = None,
|
|
requesterTenantId: str | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> list[SupportTicketRecord]:
|
|
access = access_service.assert_workspace_access(db, principal, workspace_code)
|
|
if access.can_manage or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}:
|
|
requesterId = None
|
|
requesterTenantId = None
|
|
else:
|
|
requesterId = principal.user_id
|
|
requesterTenantId = principal.tenant_id
|
|
return service.list_workspace_tickets(db, workspace_code, requesterId, requesterTenantId)
|
|
|
|
|
|
@router.get("/../tickets", include_in_schema=False)
|
|
def _noop_redirect() -> None:
|
|
return None
|
|
|
|
|
|
ticket_router = APIRouter(
|
|
prefix="/tickets",
|
|
tags=["support-tickets"],
|
|
dependencies=[Depends(get_principal)],
|
|
)
|
|
|
|
|
|
@ticket_router.get("/statuses", response_model=list[SupportFeedbackStatusRecord])
|
|
def get_feedback_statuses(
|
|
projectId: int,
|
|
channelId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> list[SupportFeedbackStatusRecord]:
|
|
return [
|
|
SupportFeedbackStatusRecord.model_validate(item)
|
|
for item in service.list_feedback_statuses(db, principal, projectId, channelId)
|
|
]
|
|
|
|
|
|
@ticket_router.get("/metrics", response_model=SupportFeedbackMetrics)
|
|
def get_feedback_metrics(
|
|
projectId: int,
|
|
channelId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SupportFeedbackMetrics:
|
|
return SupportFeedbackMetrics.model_validate(
|
|
service.get_feedback_metrics(db, principal, projectId, channelId)
|
|
)
|
|
|
|
|
|
@ticket_router.get("", response_model=list[SupportTicketRecord])
|
|
def list_tickets(
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> list[SupportTicketRecord]:
|
|
if not access_service.is_system_admin(db, principal):
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
|
return service.list_tickets(db)
|
|
|
|
|
|
@ticket_router.patch("/{ticket_id}/feedback-status", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_manager_access)])
|
|
def update_feedback_status(
|
|
ticket_id: int,
|
|
payload: FeedbackStatusUpdateRequest,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> TicketMutationResponse:
|
|
return service.update_feedback_status(
|
|
db,
|
|
ticket_id,
|
|
payload.feedback_status,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.get("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_access)])
|
|
def get_ticket(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketRecord:
|
|
return service.get_ticket(db, ticket_id, workspaceCode)
|
|
|
|
|
|
@ticket_router.get("/{ticket_id}/attachments/{attachment_id}", dependencies=[Depends(require_ticket_access)])
|
|
def download_ticket_attachment(
|
|
ticket_id: int,
|
|
attachment_id: int,
|
|
workspaceCode: str | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> Response:
|
|
attachment = service.get_ticket_attachment(db, ticket_id, attachment_id, workspaceCode)
|
|
if attachment.storage_provider.upper() == "R2":
|
|
return Response(
|
|
content=service.read_attachment(attachment),
|
|
media_type=attachment.mime_type or "application/octet-stream",
|
|
headers={
|
|
"Content-Disposition": (
|
|
"attachment; filename*=UTF-8''"
|
|
+ quote(attachment.original_file_name)
|
|
)
|
|
},
|
|
)
|
|
|
|
file_path = service.resolve_attachment_path(attachment)
|
|
return FileResponse(
|
|
path=file_path,
|
|
media_type=attachment.mime_type or "application/octet-stream",
|
|
filename=attachment.original_file_name,
|
|
)
|
|
|
|
|
|
@ticket_router.put("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_write_access)])
|
|
def update_ticket(
|
|
ticket_id: int,
|
|
payload: TicketUpdateRequest,
|
|
workspaceCode: str | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketRecord:
|
|
return service.update_ticket(db, ticket_id, payload, workspaceCode)
|
|
|
|
|
|
@ticket_router.delete("/{ticket_id}", dependencies=[Depends(require_ticket_write_access)])
|
|
def delete_ticket(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, int | bool]:
|
|
return service.delete_ticket(db, ticket_id, workspaceCode)
|
|
|
|
|
|
@ticket_router.get(
|
|
"/{ticket_id}/assignees",
|
|
response_model=list[TicketAssigneeCandidate],
|
|
dependencies=[Depends(require_ticket_manager_access)],
|
|
)
|
|
def list_ticket_assignees(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> list[TicketAssigneeCandidate]:
|
|
return service.list_assignee_candidates(
|
|
db,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.patch(
|
|
"/{ticket_id}/assignee",
|
|
response_model=SupportTicketRecord,
|
|
dependencies=[Depends(require_ticket_manager_access)],
|
|
)
|
|
def update_ticket_assignee(
|
|
ticket_id: int,
|
|
payload: TicketAssigneeUpdateRequest,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketRecord:
|
|
return service.update_assignee(
|
|
db,
|
|
ticket_id,
|
|
payload,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.get(
|
|
"/{ticket_id}/internal-memos",
|
|
response_model=list[SupportTicketCommentRecord],
|
|
response_description="관리자 전용 내부 메모 목록",
|
|
summary="내부 메모 목록 조회",
|
|
description=(
|
|
"관리자 권한이 있는 사용자만 조회할 수 있습니다. "
|
|
"일반 댓글은 반환하지 않으며, 내부 메모는 사용자용 댓글 응답에 포함되지 않습니다."
|
|
),
|
|
tags=["internal-memos"],
|
|
dependencies=[Depends(require_ticket_manager_access)],
|
|
)
|
|
def list_internal_memos(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> list[SupportTicketCommentRecord]:
|
|
return service.list_internal_memos(
|
|
db,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
principal.user_id,
|
|
principal.tenant_id,
|
|
True,
|
|
)
|
|
|
|
|
|
@ticket_router.post(
|
|
"/{ticket_id}/internal-memos",
|
|
response_model=SupportTicketCommentRecord,
|
|
response_description="생성된 내부 메모",
|
|
summary="내부 메모 등록",
|
|
description="관리자 전용 메모를 등록합니다. 내부 메모는 고객용 댓글 목록에 노출되지 않습니다.",
|
|
tags=["internal-memos"],
|
|
dependencies=[Depends(require_ticket_manager_access)],
|
|
)
|
|
def create_internal_memo(
|
|
ticket_id: int,
|
|
payload: InternalMemoCreateRequest,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketCommentRecord:
|
|
secured_payload = TicketCommentCreateRequest(
|
|
requester_id=principal.user_id,
|
|
requester_tenant_id=principal.tenant_id,
|
|
author_name=principal.name or principal.email or principal.user_id,
|
|
content=payload.content,
|
|
is_internal=True,
|
|
)
|
|
return service.create_internal_memo(
|
|
db,
|
|
ticket_id,
|
|
secured_payload,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.get(
|
|
"/{ticket_id}/comments",
|
|
response_model=list[SupportTicketCommentRecord],
|
|
response_description="공개 댓글 목록",
|
|
summary="공개 댓글 목록 조회",
|
|
description=(
|
|
"지원 요청의 공개 댓글만 반환합니다. 내부 메모는 관리자라도 이 경로에서는 반환하지 않고 "
|
|
"별도의 내부 메모 API로만 조회합니다."
|
|
),
|
|
tags=["comments"],
|
|
)
|
|
def list_ticket_comments(
|
|
ticket_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(require_ticket_access),
|
|
db: Session = Depends(get_db),
|
|
) -> list[SupportTicketCommentRecord]:
|
|
can_manage_comments = can_manage_ticket_comments(
|
|
db,
|
|
principal,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
return service.list_ticket_comments(
|
|
db,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
principal.user_id,
|
|
principal.tenant_id,
|
|
can_manage_comments,
|
|
)
|
|
|
|
|
|
@ticket_router.post(
|
|
"/{ticket_id}/comments",
|
|
response_model=SupportTicketCommentRecord,
|
|
response_description="생성된 공개 댓글",
|
|
summary="공개 댓글 등록",
|
|
description=(
|
|
"공개 댓글을 등록합니다. 작성자와 테넌트는 요청 본문을 신뢰하지 않고 "
|
|
"SSO 인증 사용자 정보로 결정합니다. 관리자 전용 메모는 내부 메모 API를 사용합니다."
|
|
),
|
|
tags=["comments"],
|
|
dependencies=[Depends(require_ticket_write_access)],
|
|
)
|
|
def create_ticket_comment(
|
|
ticket_id: int,
|
|
payload: TicketCommentCreateRequest,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketCommentRecord:
|
|
secured_payload = payload.model_copy(update={
|
|
"requester_id": principal.user_id,
|
|
"requester_tenant_id": principal.tenant_id,
|
|
"author_name": principal.name or principal.email or principal.user_id,
|
|
"is_internal": False,
|
|
})
|
|
return service.create_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
secured_payload,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.post(
|
|
"/{ticket_id}/comments/attachments",
|
|
response_model=SupportTicketCommentRecord,
|
|
response_description="첨부파일이 연결된 생성 댓글",
|
|
summary="첨부파일과 함께 공개 댓글 등록",
|
|
description=(
|
|
"multipart/form-data로 공개 댓글과 첨부파일을 등록합니다. "
|
|
"content는 댓글 본문이고 attachments는 반복 가능한 파일 필드입니다. "
|
|
"내부 메모 여부와 작성자 정보는 인증 권한으로 서버가 결정합니다."
|
|
),
|
|
tags=["comments", "attachments"],
|
|
dependencies=[Depends(require_ticket_write_access)],
|
|
)
|
|
async def create_ticket_comment_with_attachments(
|
|
ticket_id: int,
|
|
content: Annotated[str, Form(description="공개 댓글 본문")] = "",
|
|
attachments: Annotated[
|
|
list[UploadFile] | None,
|
|
File(description="댓글에 연결할 파일. 같은 필드명을 반복해 여러 파일을 첨부할 수 있습니다."),
|
|
] = None,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketCommentRecord:
|
|
payload = TicketCommentCreateRequest(
|
|
requester_id=principal.user_id,
|
|
requester_tenant_id=principal.tenant_id,
|
|
author_name=principal.name or principal.email or principal.user_id,
|
|
content=content,
|
|
is_internal=False,
|
|
)
|
|
return service.create_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
payload,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
attachments or [],
|
|
)
|
|
|
|
|
|
@ticket_router.put(
|
|
"/{ticket_id}/internal-memos/{memo_id}",
|
|
response_model=SupportTicketCommentRecord,
|
|
response_description="수정된 내부 메모",
|
|
summary="내부 메모 수정",
|
|
description="관리자 전용 내부 메모를 수정합니다.",
|
|
tags=["internal-memos"],
|
|
dependencies=[Depends(require_ticket_manager_access)],
|
|
)
|
|
def update_internal_memo(
|
|
ticket_id: int,
|
|
memo_id: int,
|
|
payload: InternalMemoUpdateRequest,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketCommentRecord:
|
|
secured_payload = TicketCommentUpdateRequest(
|
|
requester_id=principal.user_id,
|
|
requester_tenant_id=principal.tenant_id,
|
|
content=payload.content,
|
|
)
|
|
return service.update_internal_memo(
|
|
db,
|
|
ticket_id,
|
|
memo_id,
|
|
secured_payload,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.put(
|
|
"/{ticket_id}/comments/{comment_id}",
|
|
response_model=SupportTicketCommentRecord,
|
|
response_description="수정된 공개 댓글",
|
|
summary="공개 댓글 수정",
|
|
description="댓글 작성자 본인의 공개 댓글을 수정합니다. 내부 메모는 별도 내부 메모 API로 수정합니다.",
|
|
tags=["comments"],
|
|
dependencies=[Depends(require_ticket_write_access)],
|
|
)
|
|
def update_ticket_comment(
|
|
ticket_id: int,
|
|
comment_id: int,
|
|
payload: TicketCommentUpdateRequest,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> SupportTicketCommentRecord:
|
|
secured_payload = payload.model_copy(update={
|
|
"requester_id": principal.user_id,
|
|
"requester_tenant_id": principal.tenant_id,
|
|
})
|
|
return service.update_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
comment_id,
|
|
secured_payload,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
)
|
|
|
|
|
|
@ticket_router.delete(
|
|
"/{ticket_id}/internal-memos/{memo_id}",
|
|
response_model=TicketDeleteResponse,
|
|
response_description="삭제 결과",
|
|
summary="내부 메모 삭제",
|
|
description="관리자 전용 내부 메모를 삭제합니다.",
|
|
tags=["internal-memos"],
|
|
dependencies=[Depends(require_ticket_manager_access)],
|
|
)
|
|
def delete_internal_memo(
|
|
ticket_id: int,
|
|
memo_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, int | bool]:
|
|
return service.delete_internal_memo(
|
|
db,
|
|
ticket_id,
|
|
memo_id,
|
|
principal.user_id,
|
|
principal.tenant_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
True,
|
|
)
|
|
|
|
|
|
@ticket_router.delete(
|
|
"/{ticket_id}/comments/{comment_id}",
|
|
response_model=TicketDeleteResponse,
|
|
response_description="삭제 결과",
|
|
summary="공개 댓글 삭제",
|
|
description="댓글 작성자 본인 또는 관리자 권한으로 공개 댓글을 삭제합니다.",
|
|
tags=["comments"],
|
|
dependencies=[Depends(require_ticket_write_access)],
|
|
)
|
|
def delete_ticket_comment(
|
|
ticket_id: int,
|
|
comment_id: int,
|
|
workspaceCode: str | None = None,
|
|
abcFeedbackId: int | None = None,
|
|
principal: SsoPrincipal = Depends(get_principal),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, int | bool]:
|
|
return service.delete_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
comment_id,
|
|
principal.user_id,
|
|
principal.tenant_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
can_manage_ticket_comments(
|
|
db,
|
|
principal,
|
|
ticket_id,
|
|
workspaceCode,
|
|
abcFeedbackId,
|
|
),
|
|
)
|
|
|
|
|
|
@ticket_router.post("/{ticket_id}/approve", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
|
|
def approve_ticket(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
|
|
return service.approve_ticket(db, ticket_id)
|
|
|
|
|
|
@ticket_router.post("/{ticket_id}/issue", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
|
|
def create_issue(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
|
|
return service.create_issue(db, ticket_id)
|
|
|
|
|
|
@ticket_router.post("/{ticket_id}/issue-link-status", dependencies=[Depends(require_ticket_write_access)])
|
|
def sync_issue_link_status(
|
|
ticket_id: int,
|
|
payload: TicketIssueLinkStatusSyncRequest,
|
|
workspaceCode: str | None = None,
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, int | str | bool]:
|
|
return service.sync_issue_link_status(db, ticket_id, payload.linked, workspaceCode)
|