1942 lines
72 KiB
Python
1942 lines
72 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from uuid import uuid4
|
|
from urllib import error, request
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import HTTPException, UploadFile
|
|
from sqlalchemy import and_, or_, select, text as sql_text
|
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
|
|
|
from app.core.config import ABCProjectTarget, settings
|
|
from app.db.models import ABCFeedbackMapping, Attachment, RequestApproval, SupportRoleAssignment, SupportTicket, SupportUser, TicketComment, Workspace, WorkspaceChannelMapping
|
|
from app.services.access_service import AccessService
|
|
from app.services.workspace_mapping_service import workspace_mapping_service
|
|
from app.services.storage_service import storage_service
|
|
from app.schemas.ticket import (
|
|
SupportAttachmentRecord,
|
|
SupportTicketCommentRecord,
|
|
SupportActivityItem,
|
|
SupportTicketRecord,
|
|
TicketCommentCreateRequest,
|
|
TicketCommentUpdateRequest,
|
|
TicketCreateRequest,
|
|
TicketCreateResponse,
|
|
TicketAssigneeCandidate,
|
|
TicketAssigneeUpdateRequest,
|
|
TicketUpdateRequest,
|
|
TicketMutationResponse,
|
|
WorkspaceFormField,
|
|
WorkspaceFormTemplateResponse,
|
|
WorkspaceSummary,
|
|
)
|
|
|
|
|
|
SEOUL_TIMEZONE = ZoneInfo("Asia/Seoul")
|
|
QNA_CATEGORY_CODES = {"ERROR_QNA", "IMPROVEMENT_QNA", "GENERAL_QNA"}
|
|
QNA_CATEGORY_OPTIONS = [
|
|
{"id": 1, "key": "ERROR_QNA", "name": "오류 문의"},
|
|
{"id": 2, "key": "IMPROVEMENT_QNA", "name": "개선 문의"},
|
|
{"id": 3, "key": "GENERAL_QNA", "name": "일반 문의"},
|
|
]
|
|
access_service = AccessService()
|
|
|
|
|
|
def serialize_support_timestamp(value: datetime | None) -> str | None:
|
|
"""Serialize DB timestamps with an explicit Asia/Seoul offset.
|
|
|
|
MySQL timestamps are created/read in Asia/Seoul in staging. SQLAlchemy
|
|
returns those values as naive datetimes, so treat a naive value as Seoul
|
|
time before serializing it. Including the offset prevents the browser from
|
|
interpreting the API response in its own local timezone.
|
|
"""
|
|
|
|
if value is None:
|
|
return None
|
|
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=SEOUL_TIMEZONE)
|
|
|
|
return value.astimezone(SEOUL_TIMEZONE).isoformat()
|
|
|
|
|
|
def now_support_timestamp() -> datetime:
|
|
"""Return a naive Asia/Seoul timestamp for MySQL TIMESTAMP columns."""
|
|
|
|
return datetime.now(SEOUL_TIMEZONE).replace(tzinfo=None)
|
|
|
|
|
|
class TicketService:
|
|
def list_workspaces(self, db: Session) -> list[WorkspaceSummary]:
|
|
workspaces = db.execute(
|
|
select(Workspace).where(Workspace.is_active.is_(True)).order_by(Workspace.id.asc()),
|
|
).scalars().all()
|
|
|
|
return [
|
|
WorkspaceSummary(
|
|
workspace_code=workspace.workspace_code,
|
|
workspace_name=workspace.workspace_name,
|
|
workspace_type=workspace.workspace_type,
|
|
)
|
|
for workspace in workspaces
|
|
]
|
|
|
|
def get_workspace_form_template(self, db: Session, workspace_code: str) -> WorkspaceFormTemplateResponse:
|
|
workspace = self._get_workspace_by_code(db, workspace_code, raise_not_found=False)
|
|
|
|
qna_fields = [
|
|
WorkspaceFormField(
|
|
field_code="category",
|
|
label="구분",
|
|
field_type="select",
|
|
required=True,
|
|
options=QNA_CATEGORY_OPTIONS,
|
|
),
|
|
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
|
|
WorkspaceFormField(field_code="ip_address", label="사용자 IP 주소", field_type="text"),
|
|
WorkspaceFormField(field_code="mac_address", label="MAC 주소", field_type="text"),
|
|
]
|
|
|
|
templates = {
|
|
"INTRA_BOOK_REQUEST": WorkspaceFormTemplateResponse(
|
|
workspace_code="INTRA_BOOK_REQUEST",
|
|
workspace_name=workspace.workspace_name if workspace else "인트라넷 도서 신청",
|
|
requires_approval=True,
|
|
fields=[
|
|
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
|
|
WorkspaceFormField(field_code="requester_contact", label="연락처", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="book_title", label="도서명", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="author", label="저자", field_type="text"),
|
|
WorkspaceFormField(field_code="purchase_reason", label="구매 사유", field_type="textarea", required=True),
|
|
],
|
|
),
|
|
"Q&A_Platform": WorkspaceFormTemplateResponse(
|
|
workspace_code="Q&A_Platform",
|
|
workspace_name=workspace.workspace_name if workspace else "인트라넷 일반 문의",
|
|
requires_approval=False,
|
|
fields=[
|
|
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
|
|
WorkspaceFormField(field_code="requester_contact", label="연락처", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="ip_address", label="사용자 IP 주소", field_type="text"),
|
|
WorkspaceFormField(field_code="mac_address", label="MAC 주소", field_type="text"),
|
|
WorkspaceFormField(field_code="environment", label="환경", field_type="text"),
|
|
WorkspaceFormField(field_code="error_message", label="오류 메시지", field_type="textarea"),
|
|
WorkspaceFormField(field_code="expected_result", label="기대 결과", field_type="textarea"),
|
|
],
|
|
),
|
|
"EGBIM_DEMO": WorkspaceFormTemplateResponse(
|
|
workspace_code="EGBIM_DEMO",
|
|
workspace_name=workspace.workspace_name if workspace else "EGBIM_DEMO",
|
|
requires_approval=False,
|
|
fields=qna_fields,
|
|
),
|
|
}
|
|
|
|
return templates.get(
|
|
workspace_code,
|
|
WorkspaceFormTemplateResponse(
|
|
workspace_code=workspace_code,
|
|
workspace_name=workspace.workspace_name if workspace else "미등록 워크스페이스",
|
|
requires_approval=False,
|
|
fields=[
|
|
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
|
|
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
|
|
],
|
|
),
|
|
)
|
|
|
|
def create_ticket(
|
|
self,
|
|
db: Session,
|
|
payload: TicketCreateRequest,
|
|
attachments: list[UploadFile] | None = None,
|
|
) -> TicketCreateResponse:
|
|
workspace = self._get_workspace_by_code(db, payload.workspace_code)
|
|
|
|
approval_status = "PENDING" if payload.requires_approval else "NOT_REQUIRED"
|
|
status_code = "PENDING_APPROVAL" if payload.requires_approval else "RECEIVED"
|
|
issue_link_status = "NOT_REQUIRED" if payload.requires_approval else "NOT_REQUIRED"
|
|
|
|
ticket = SupportTicket(
|
|
workspace_id=workspace.id,
|
|
requester_id=payload.requester_id,
|
|
requester_tenant_id=payload.requester_tenant_id,
|
|
requester_contact=payload.requester_contact or "",
|
|
requester_email=payload.requester_email,
|
|
requester_name=payload.requester_name,
|
|
requester_department=payload.requester_department,
|
|
requester_phone_number=payload.requester_phone_number,
|
|
ticket_type=payload.ticket_type,
|
|
title=payload.title,
|
|
description=payload.description,
|
|
category_code=self._resolve_category_code(
|
|
payload.workspace_code,
|
|
payload.category_code,
|
|
),
|
|
status_code=status_code,
|
|
feedback_status="INIT",
|
|
approval_status=approval_status,
|
|
sync_status="PENDING",
|
|
issue_link_status=issue_link_status,
|
|
is_secret=payload.is_secret,
|
|
requires_approval=payload.requires_approval,
|
|
extra_fields=payload.extra_fields,
|
|
)
|
|
db.add(ticket)
|
|
db.flush()
|
|
|
|
abc_feedback_id = self._create_abc_feedback(db, payload)
|
|
ticket.sync_status = "SYNCED"
|
|
target = self._resolve_workspace_target(db, payload.workspace_code)
|
|
|
|
mapping = ABCFeedbackMapping(
|
|
ticket_id=ticket.id,
|
|
workspace_id=workspace.id,
|
|
abc_channel_id=str(target.channel_id),
|
|
abc_feedback_id=str(abc_feedback_id),
|
|
abc_feedback_url=(
|
|
f"/main/project/{target.project_id}/feedback?channelId={target.channel_id}"
|
|
),
|
|
sync_status="SYNCED",
|
|
)
|
|
db.add(mapping)
|
|
|
|
if attachments:
|
|
self._save_ticket_attachments(db, workspace, ticket, payload, attachments)
|
|
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
|
|
return TicketCreateResponse(
|
|
ticket_id=ticket.id,
|
|
workspace_code=payload.workspace_code,
|
|
status_code=ticket.status_code,
|
|
feedback_status=ticket.feedback_status,
|
|
sync_status=ticket.sync_status,
|
|
message="ABC 피드백과 내부 티켓이 함께 저장되었습니다."
|
|
)
|
|
|
|
def _create_abc_feedback(
|
|
self,
|
|
db: Session,
|
|
payload: TicketCreateRequest,
|
|
) -> int:
|
|
target = self._resolve_workspace_target(db, payload.workspace_code)
|
|
api_key = self._resolve_api_key(target)
|
|
if not api_key:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="ABC API key is not configured.",
|
|
)
|
|
|
|
endpoint = (
|
|
f"{settings.abc_api_base_url}/api/projects/"
|
|
f"{target.project_id}/channels/{target.channel_id}/feedbacks"
|
|
)
|
|
body = json.dumps(
|
|
{
|
|
"title": payload.title,
|
|
"contents": payload.description,
|
|
}
|
|
).encode("utf-8")
|
|
req = request.Request(
|
|
endpoint,
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"x-api-key": api_key,
|
|
},
|
|
method="POST",
|
|
)
|
|
|
|
try:
|
|
with request.urlopen(req, timeout=10) as response:
|
|
payload_data = json.loads(response.read().decode("utf-8"))
|
|
except error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"ABC feedback creation failed: {detail}",
|
|
) from exc
|
|
except error.URLError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"ABC API connection failed: {exc.reason}",
|
|
) from exc
|
|
|
|
feedback_id = payload_data.get("id")
|
|
if not isinstance(feedback_id, int):
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="ABC API did not return a feedback id.",
|
|
)
|
|
|
|
return feedback_id
|
|
|
|
def _update_abc_feedback(
|
|
self,
|
|
db: Session,
|
|
workspace_code: str,
|
|
feedback_id: int,
|
|
body: dict[str, str],
|
|
) -> None:
|
|
target = self._resolve_workspace_target(db, workspace_code)
|
|
api_key = self._resolve_api_key(target)
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
|
|
|
|
endpoint = (
|
|
f"{settings.abc_api_base_url}/api/projects/"
|
|
f"{target.project_id}/channels/{target.channel_id}/feedbacks/{feedback_id}"
|
|
)
|
|
req = request.Request(
|
|
endpoint,
|
|
data=json.dumps(body).encode("utf-8"),
|
|
headers={"Content-Type": "application/json", "x-api-key": api_key},
|
|
method="PUT",
|
|
)
|
|
|
|
try:
|
|
with request.urlopen(req, timeout=10):
|
|
return
|
|
except error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"ABC feedback update failed: {detail}",
|
|
) from exc
|
|
except error.URLError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"ABC API connection failed: {exc.reason}",
|
|
) from exc
|
|
|
|
def _delete_abc_feedback(
|
|
self,
|
|
db: Session,
|
|
workspace_code: str,
|
|
feedback_id: int,
|
|
) -> None:
|
|
target = self._resolve_workspace_target(db, workspace_code)
|
|
api_key = self._resolve_api_key(target)
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
|
|
|
|
endpoint = (
|
|
f"{settings.abc_api_base_url}/api/projects/"
|
|
f"{target.project_id}/channels/{target.channel_id}/feedbacks"
|
|
)
|
|
req = request.Request(
|
|
endpoint,
|
|
data=json.dumps({"feedbackIds": [feedback_id]}).encode("utf-8"),
|
|
headers={"Content-Type": "application/json", "x-api-key": api_key},
|
|
method="DELETE",
|
|
)
|
|
|
|
try:
|
|
with request.urlopen(req, timeout=10):
|
|
return
|
|
except error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"ABC feedback deletion failed: {detail}",
|
|
) from exc
|
|
except error.URLError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"ABC API connection failed: {exc.reason}",
|
|
) from exc
|
|
|
|
def _save_ticket_attachments(
|
|
self,
|
|
db: Session,
|
|
workspace: Workspace,
|
|
ticket: SupportTicket,
|
|
payload: TicketCreateRequest,
|
|
attachments: list[UploadFile],
|
|
) -> None:
|
|
self._save_attachments(
|
|
db,
|
|
workspace,
|
|
ticket,
|
|
payload.requester_id,
|
|
payload.requester_tenant_id,
|
|
attachments,
|
|
)
|
|
|
|
def _save_attachments(
|
|
self,
|
|
db: Session,
|
|
workspace: Workspace,
|
|
ticket: SupportTicket,
|
|
uploader_id: str,
|
|
uploader_tenant_id: str,
|
|
attachments: list[UploadFile],
|
|
comment_id: int | None = None,
|
|
) -> None:
|
|
target = self._resolve_workspace_target(db, workspace.workspace_code)
|
|
upload_root = Path(settings.upload_root_dir)
|
|
relative_directory = self._build_attachment_directory(target)
|
|
absolute_directory = upload_root / relative_directory
|
|
if not storage_service.enabled:
|
|
absolute_directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
saved_file_paths: list[Path] = []
|
|
uploaded_storage_keys: list[str] = []
|
|
|
|
try:
|
|
for attachment in attachments:
|
|
original_file_name = attachment.filename or "attachment.bin"
|
|
file_bytes = attachment.file.read()
|
|
|
|
if len(file_bytes) > settings.upload_max_file_size_mb * 1024 * 1024:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"첨부파일은 최대 {settings.upload_max_file_size_mb}MB까지 업로드할 수 있습니다.",
|
|
)
|
|
|
|
sanitized_name = self._sanitize_file_name(original_file_name)
|
|
file_extension = Path(sanitized_name).suffix
|
|
stored_file_name = f"{uuid4().hex}{file_extension}"
|
|
storage_key = str(
|
|
Path("support")
|
|
/ self._sanitize_path_segment(workspace.workspace_code)
|
|
/ str(target.project_id)
|
|
/ str(target.channel_id)
|
|
/ str(ticket.id)
|
|
/ (f"comment-{comment_id}" if comment_id is not None else "feedback")
|
|
/ stored_file_name
|
|
)
|
|
|
|
if storage_service.enabled:
|
|
try:
|
|
storage_service.put_bytes(
|
|
storage_key,
|
|
file_bytes,
|
|
attachment.content_type,
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="R2 첨부파일 저장에 실패했습니다.",
|
|
) from exc
|
|
uploaded_storage_keys.append(storage_key)
|
|
storage_provider = "R2"
|
|
storage_bucket = settings.r2_bucket
|
|
else:
|
|
absolute_path = absolute_directory / stored_file_name
|
|
absolute_path.write_bytes(file_bytes)
|
|
saved_file_paths.append(absolute_path)
|
|
storage_provider = "LOCAL"
|
|
storage_bucket = str(upload_root)
|
|
|
|
db.add(
|
|
Attachment(
|
|
ticket_id=ticket.id,
|
|
comment_id=comment_id,
|
|
workspace_id=workspace.id,
|
|
uploader_id=uploader_id,
|
|
uploader_tenant_id=uploader_tenant_id,
|
|
original_file_name=original_file_name,
|
|
stored_file_name=stored_file_name,
|
|
storage_provider=storage_provider,
|
|
storage_bucket=storage_bucket,
|
|
storage_key=(
|
|
storage_key
|
|
if storage_service.enabled
|
|
else str(relative_directory / stored_file_name)
|
|
),
|
|
mime_type=attachment.content_type,
|
|
file_extension=file_extension.lstrip(".") or None,
|
|
file_size=len(file_bytes),
|
|
checksum_sha256=hashlib.sha256(file_bytes).hexdigest(),
|
|
)
|
|
)
|
|
except Exception:
|
|
for saved_file_path in saved_file_paths:
|
|
if saved_file_path.exists():
|
|
saved_file_path.unlink()
|
|
for storage_key in uploaded_storage_keys:
|
|
try:
|
|
storage_service.delete(storage_key)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
def _build_attachment_directory(self, target: ABCProjectTarget) -> Path:
|
|
project_id = target.project_id
|
|
channel_id = target.channel_id
|
|
project_name = self._sanitize_path_segment(
|
|
str(target.project_name or f"project-{project_id}")
|
|
)
|
|
channel_name = self._sanitize_path_segment(
|
|
str(target.channel_name or f"channel-{channel_id}")
|
|
)
|
|
return Path(project_name) / channel_name
|
|
|
|
def list_workspace_tickets(
|
|
self,
|
|
db: Session,
|
|
workspace_code: str,
|
|
requester_id: str | None,
|
|
requester_tenant_id: str | None,
|
|
) -> list[SupportTicketRecord]:
|
|
statement = (
|
|
select(SupportTicket)
|
|
.join(Workspace)
|
|
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.attachments))
|
|
.where(
|
|
Workspace.workspace_code == workspace_code,
|
|
SupportTicket.sync_status != "DELETED",
|
|
)
|
|
.order_by(SupportTicket.id.desc())
|
|
)
|
|
|
|
if requester_id:
|
|
statement = statement.where(SupportTicket.requester_id == requester_id)
|
|
if requester_tenant_id:
|
|
statement = statement.where(SupportTicket.requester_tenant_id == requester_tenant_id)
|
|
|
|
tickets = db.execute(statement).unique().scalars().all()
|
|
return [self._serialize_ticket(ticket, db) for ticket in tickets]
|
|
|
|
FEEDBACK_STATUSES = {"INIT", "ON_REVIEW", "DETAILED_REVIEW", "IN_PROGRESS", "RESOLVED", "PENDING"}
|
|
|
|
def update_feedback_status(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
feedback_status: str,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> TicketMutationResponse:
|
|
if feedback_status not in self.FEEDBACK_STATUSES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"지원하지 않는 피드백 처리 상태입니다: {feedback_status}",
|
|
)
|
|
|
|
ticket = self._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
# This field belongs to the feedback lifecycle. It deliberately does
|
|
# not change status_code or issue_link_status.
|
|
ticket.feedback_status = feedback_status
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
return self._serialize_mutation(ticket)
|
|
|
|
def list_feedback_statuses(
|
|
self,
|
|
db: Session,
|
|
principal,
|
|
project_id: int,
|
|
channel_id: int | None = None,
|
|
) -> list[dict[str, int | str]]:
|
|
mappings = db.execute(
|
|
select(WorkspaceChannelMapping, Workspace)
|
|
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
|
|
.where(
|
|
WorkspaceChannelMapping.abc_project_id == project_id,
|
|
WorkspaceChannelMapping.is_active.is_(True),
|
|
Workspace.is_active.is_(True),
|
|
)
|
|
).all()
|
|
|
|
allowed_channels_by_workspace: dict[int, set[str]] = {}
|
|
for mapping, workspace in mappings:
|
|
if channel_id is not None and str(mapping.abc_channel_id) != str(channel_id):
|
|
continue
|
|
access = access_service.find_workspace_access(
|
|
db,
|
|
principal,
|
|
workspace.workspace_code,
|
|
)
|
|
if access is not None and (
|
|
access.can_manage
|
|
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
|
):
|
|
allowed_channels_by_workspace.setdefault(workspace.id, set()).add(
|
|
str(mapping.abc_channel_id)
|
|
)
|
|
|
|
if not allowed_channels_by_workspace:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="프로젝트 피드백 상태를 조회할 권한이 없습니다.",
|
|
)
|
|
|
|
rows = db.execute(
|
|
select(
|
|
ABCFeedbackMapping.abc_feedback_id,
|
|
SupportTicket.feedback_status,
|
|
SupportTicket.updated_at,
|
|
ABCFeedbackMapping.workspace_id,
|
|
ABCFeedbackMapping.abc_channel_id,
|
|
)
|
|
.join(SupportTicket, SupportTicket.id == ABCFeedbackMapping.ticket_id)
|
|
.where(
|
|
ABCFeedbackMapping.workspace_id.in_(allowed_channels_by_workspace),
|
|
SupportTicket.sync_status != "DELETED",
|
|
)
|
|
.order_by(SupportTicket.updated_at.desc(), SupportTicket.id.desc())
|
|
).all()
|
|
|
|
result: list[dict[str, int | str]] = []
|
|
for feedback_id, status, updated_at, workspace_id, mapped_channel_id in rows:
|
|
if str(mapped_channel_id) not in allowed_channels_by_workspace.get(workspace_id, set()):
|
|
continue
|
|
if not str(feedback_id).isdigit() or updated_at is None:
|
|
continue
|
|
result.append({
|
|
"feedback_id": int(feedback_id),
|
|
"feedback_status": status,
|
|
"updated_at": serialize_support_timestamp(updated_at) or "",
|
|
})
|
|
return result
|
|
|
|
def get_feedback_metrics(
|
|
self,
|
|
db: Session,
|
|
principal,
|
|
project_id: int,
|
|
channel_id: int | None = None,
|
|
) -> dict[str, int | float | None]:
|
|
mappings = db.execute(
|
|
select(WorkspaceChannelMapping, Workspace)
|
|
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
|
|
.where(
|
|
WorkspaceChannelMapping.abc_project_id == project_id,
|
|
WorkspaceChannelMapping.is_active.is_(True),
|
|
Workspace.is_active.is_(True),
|
|
)
|
|
).all()
|
|
|
|
accessible_workspace_ids: set[int] = set()
|
|
for mapping, workspace in mappings:
|
|
if channel_id is not None and str(mapping.abc_channel_id) != str(channel_id):
|
|
continue
|
|
access = access_service.find_workspace_access(
|
|
db,
|
|
principal,
|
|
workspace.workspace_code,
|
|
)
|
|
if access is not None and (
|
|
access.can_manage
|
|
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
|
):
|
|
accessible_workspace_ids.add(workspace.id)
|
|
|
|
if not accessible_workspace_ids:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="프로젝트 피드백 지표를 조회할 권한이 없습니다.",
|
|
)
|
|
|
|
tickets = db.execute(
|
|
select(SupportTicket)
|
|
.options(selectinload(SupportTicket.comments))
|
|
.where(
|
|
SupportTicket.workspace_id.in_(accessible_workspace_ids),
|
|
SupportTicket.sync_status != "DELETED",
|
|
)
|
|
).scalars().all()
|
|
today_start = now_support_timestamp().replace(
|
|
hour=0,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
today_first_count = sum(
|
|
1 for ticket in tickets if ticket.created_at >= today_start
|
|
)
|
|
issue_linked_count = sum(
|
|
1 for ticket in tickets if ticket.issue_link_status == "LINKED"
|
|
)
|
|
answer_waiting_count = sum(
|
|
1
|
|
for ticket in tickets
|
|
if not any(
|
|
comment.is_internal
|
|
and comment.comment_type != "INTERNAL_MEMO"
|
|
and comment.deleted_at is None
|
|
for comment in ticket.comments
|
|
)
|
|
or ticket.issue_link_status != "LINKED"
|
|
)
|
|
processing_minutes: list[float] = []
|
|
for ticket in tickets:
|
|
processed_at_candidates = [
|
|
comment.created_at
|
|
for comment in ticket.comments
|
|
if comment.is_internal
|
|
and comment.comment_type != "INTERNAL_MEMO"
|
|
and comment.deleted_at is None
|
|
]
|
|
if (
|
|
ticket.issue_link_status == "LINKED"
|
|
and ticket.updated_at > ticket.created_at
|
|
):
|
|
processed_at_candidates.append(ticket.updated_at)
|
|
if processed_at_candidates:
|
|
processed_at = min(processed_at_candidates)
|
|
processing_minutes.append(
|
|
max(
|
|
0.0,
|
|
(processed_at - ticket.created_at).total_seconds() / 60,
|
|
)
|
|
)
|
|
|
|
return {
|
|
"project_id": project_id,
|
|
"channel_id": channel_id,
|
|
"today_first_count": today_first_count,
|
|
"answer_waiting_count": answer_waiting_count,
|
|
"issue_linked_count": issue_linked_count,
|
|
"issue_link_rate": round(
|
|
issue_linked_count / len(tickets) * 100,
|
|
1,
|
|
) if tickets else 0,
|
|
"average_processing_minutes": (
|
|
round(sum(processing_minutes) / len(processing_minutes), 1)
|
|
if processing_minutes
|
|
else None
|
|
),
|
|
}
|
|
|
|
def list_tickets(self, db: Session) -> list[SupportTicketRecord]:
|
|
tickets = db.execute(
|
|
select(SupportTicket)
|
|
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
|
|
.where(SupportTicket.sync_status != "DELETED")
|
|
.order_by(SupportTicket.id.desc()),
|
|
).unique().scalars().all()
|
|
return [self._serialize_ticket(ticket, db) for ticket in tickets]
|
|
|
|
def get_ticket(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
workspace_code: str | None = None,
|
|
) -> SupportTicketRecord:
|
|
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
return self._serialize_ticket(ticket, db)
|
|
|
|
def list_assignee_candidates(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> list[TicketAssigneeCandidate]:
|
|
ticket = self._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
assignments = (
|
|
db.query(SupportRoleAssignment, SupportUser)
|
|
.join(SupportUser, SupportRoleAssignment.support_user_id == SupportUser.id)
|
|
.filter(
|
|
SupportUser.status == "ACTIVE",
|
|
or_(
|
|
SupportRoleAssignment.role_code.in_({"SYSTEM_ADMIN", "SUPER_ADMIN"}),
|
|
and_(
|
|
SupportRoleAssignment.workspace_id == ticket.workspace_id,
|
|
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
|
|
),
|
|
),
|
|
)
|
|
.order_by(SupportUser.name.asc(), SupportUser.email.asc())
|
|
.all()
|
|
)
|
|
|
|
candidates: dict[tuple[str, str], TicketAssigneeCandidate] = {}
|
|
for assignment, user in assignments:
|
|
key = (user.sso_subject, user.tenant_id)
|
|
existing = candidates.get(key)
|
|
if existing is not None and existing.role_code in {"SYSTEM_ADMIN", "SUPER_ADMIN"}:
|
|
continue
|
|
candidates[key] = TicketAssigneeCandidate(
|
|
user_id=user.sso_subject,
|
|
tenant_id=user.tenant_id,
|
|
name=user.name,
|
|
email=user.email,
|
|
department=user.department,
|
|
phone_number=user.phone_number,
|
|
role_code=assignment.role_code,
|
|
)
|
|
return list(candidates.values())
|
|
|
|
def update_assignee(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
payload: TicketAssigneeUpdateRequest,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> SupportTicketRecord:
|
|
ticket = self._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
if payload.assignee_id is None and payload.assignee_tenant_id is None:
|
|
ticket.current_assignee_id = None
|
|
ticket.current_assignee_tenant_id = None
|
|
elif not payload.assignee_id or not payload.assignee_tenant_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="담당자 ID와 tenant ID를 함께 입력해야 합니다.",
|
|
)
|
|
else:
|
|
candidate = (
|
|
db.query(SupportRoleAssignment)
|
|
.join(SupportUser, SupportRoleAssignment.support_user_id == SupportUser.id)
|
|
.filter(
|
|
SupportUser.status == "ACTIVE",
|
|
SupportUser.sso_subject == payload.assignee_id,
|
|
SupportUser.tenant_id == payload.assignee_tenant_id,
|
|
or_(
|
|
SupportRoleAssignment.role_code.in_({"SYSTEM_ADMIN", "SUPER_ADMIN"}),
|
|
and_(
|
|
SupportRoleAssignment.workspace_id == ticket.workspace_id,
|
|
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
|
|
),
|
|
),
|
|
)
|
|
.first()
|
|
)
|
|
if candidate is None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="해당 프로젝트의 관리자만 담당자로 지정할 수 있습니다.",
|
|
)
|
|
ticket.current_assignee_id = payload.assignee_id
|
|
ticket.current_assignee_tenant_id = payload.assignee_tenant_id
|
|
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
return self._serialize_ticket(ticket, db)
|
|
|
|
def update_ticket(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
payload: TicketUpdateRequest,
|
|
workspace_code: str | None = None,
|
|
) -> SupportTicketRecord:
|
|
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
resolved_workspace_code = ticket.workspace.workspace_code
|
|
abc_feedback_id = int(
|
|
ticket.feedback_mapping.abc_feedback_id
|
|
if ticket.feedback_mapping
|
|
else ticket_id
|
|
)
|
|
self._update_abc_feedback(
|
|
db,
|
|
resolved_workspace_code,
|
|
abc_feedback_id,
|
|
{
|
|
"title": payload.title if payload.title is not None else ticket.title,
|
|
"contents": (
|
|
payload.description
|
|
if payload.description is not None
|
|
else ticket.description
|
|
),
|
|
},
|
|
)
|
|
|
|
if payload.title is not None:
|
|
ticket.title = payload.title.strip()
|
|
if payload.description is not None:
|
|
ticket.description = payload.description.strip()
|
|
if payload.requester_contact is not None:
|
|
ticket.requester_contact = payload.requester_contact.strip()
|
|
if payload.is_secret is not None:
|
|
ticket.is_secret = payload.is_secret
|
|
if payload.extra_fields is not None:
|
|
ticket.extra_fields = payload.extra_fields
|
|
ticket.sync_status = "SYNCED"
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
return self._serialize_ticket(ticket, db)
|
|
|
|
def delete_ticket(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
workspace_code: str | None = None,
|
|
) -> dict[str, int | bool]:
|
|
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
resolved_workspace_code = ticket.workspace.workspace_code
|
|
abc_feedback_id = int(
|
|
ticket.feedback_mapping.abc_feedback_id
|
|
if ticket.feedback_mapping
|
|
else ticket_id
|
|
)
|
|
self._delete_abc_feedback(db, resolved_workspace_code, abc_feedback_id)
|
|
ticket.sync_status = "DELETED"
|
|
if ticket.feedback_mapping:
|
|
ticket.feedback_mapping.sync_status = "DELETED"
|
|
db.commit()
|
|
|
|
return {"deleted": True, "ticket_id": ticket_id, "feedback_id": abc_feedback_id}
|
|
|
|
def list_ticket_comments(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
requester_id: str | None = None,
|
|
requester_tenant_id: str | None = None,
|
|
can_manage_comments: bool = False,
|
|
include_internal_memos: bool = False,
|
|
) -> list[SupportTicketCommentRecord]:
|
|
ticket = self._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
|
|
if not ticket:
|
|
return []
|
|
|
|
comment_conditions = [
|
|
TicketComment.ticket_id == ticket.id,
|
|
TicketComment.deleted_at.is_(None),
|
|
]
|
|
if not include_internal_memos:
|
|
comment_conditions.append(
|
|
TicketComment.comment_type != "INTERNAL_MEMO"
|
|
)
|
|
|
|
comments = db.execute(
|
|
select(TicketComment)
|
|
.options(selectinload(TicketComment.attachments))
|
|
.where(*comment_conditions)
|
|
.order_by(TicketComment.created_at.asc(), TicketComment.id.asc()),
|
|
).scalars().all()
|
|
return [
|
|
self._serialize_comment(
|
|
comment,
|
|
requester_id,
|
|
requester_tenant_id,
|
|
can_manage_comments,
|
|
)
|
|
for comment in comments
|
|
]
|
|
|
|
def list_internal_memos(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
requester_id: str | None = None,
|
|
requester_tenant_id: str | None = None,
|
|
can_manage_comments: bool = False,
|
|
) -> list[SupportTicketCommentRecord]:
|
|
return [
|
|
comment
|
|
for comment in self.list_ticket_comments(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
requester_id,
|
|
requester_tenant_id,
|
|
can_manage_comments,
|
|
True,
|
|
)
|
|
if comment.is_internal and comment.comment_type == "INTERNAL_MEMO"
|
|
]
|
|
|
|
def create_internal_memo(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
payload: TicketCommentCreateRequest,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> SupportTicketCommentRecord:
|
|
return self.create_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
payload.model_copy(update={"is_internal": True}),
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
comment_type="INTERNAL_MEMO",
|
|
)
|
|
|
|
def update_internal_memo(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
memo_id: int,
|
|
payload: TicketCommentUpdateRequest,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> SupportTicketCommentRecord:
|
|
comment = self._get_comment(
|
|
db,
|
|
ticket_id,
|
|
memo_id,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
)
|
|
if not comment.is_internal:
|
|
raise HTTPException(status_code=404, detail="내부 메모를 찾을 수 없습니다.")
|
|
return self.update_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
memo_id,
|
|
payload,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
)
|
|
|
|
def delete_internal_memo(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
memo_id: int,
|
|
requester_id: str,
|
|
requester_tenant_id: str,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
can_manage_comments: bool = False,
|
|
) -> dict[str, int | bool]:
|
|
comment = self._get_comment(
|
|
db,
|
|
ticket_id,
|
|
memo_id,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
)
|
|
if not comment.is_internal:
|
|
raise HTTPException(status_code=404, detail="내부 메모를 찾을 수 없습니다.")
|
|
return self.delete_ticket_comment(
|
|
db,
|
|
ticket_id,
|
|
memo_id,
|
|
requester_id,
|
|
requester_tenant_id,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
can_manage_comments,
|
|
)
|
|
|
|
def create_ticket_comment(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
payload: TicketCommentCreateRequest,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
attachments: list[UploadFile] | None = None,
|
|
comment_type: str = "ADMIN",
|
|
) -> SupportTicketCommentRecord:
|
|
ticket = self._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
requester_id=payload.requester_id,
|
|
requester_tenant_id=payload.requester_tenant_id,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
comment = TicketComment(
|
|
ticket_id=ticket.id,
|
|
author_id=payload.requester_id,
|
|
author_tenant_id=payload.requester_tenant_id,
|
|
author_name=payload.author_name or payload.requester_id,
|
|
content=payload.content.strip(),
|
|
is_internal=payload.is_internal,
|
|
comment_type=comment_type if payload.is_internal else "COMMENT",
|
|
sync_status="LOCAL_ONLY",
|
|
)
|
|
db.add(comment)
|
|
db.flush()
|
|
if attachments:
|
|
self._save_attachments(
|
|
db,
|
|
ticket.workspace,
|
|
ticket,
|
|
payload.requester_id,
|
|
payload.requester_tenant_id,
|
|
attachments,
|
|
comment_id=comment.id,
|
|
)
|
|
db.commit()
|
|
db.refresh(comment)
|
|
|
|
return self._serialize_comment(
|
|
comment,
|
|
payload.requester_id,
|
|
payload.requester_tenant_id,
|
|
)
|
|
|
|
def update_ticket_comment(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
comment_id: int,
|
|
payload: TicketCommentUpdateRequest,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> SupportTicketCommentRecord:
|
|
comment = self._get_comment(
|
|
db,
|
|
ticket_id,
|
|
comment_id,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
)
|
|
self._assert_comment_author(
|
|
comment,
|
|
payload.requester_id,
|
|
payload.requester_tenant_id,
|
|
)
|
|
|
|
comment.content = payload.content.strip()
|
|
comment.edited_at = now_support_timestamp()
|
|
db.commit()
|
|
db.refresh(comment)
|
|
|
|
return self._serialize_comment(
|
|
comment,
|
|
payload.requester_id,
|
|
payload.requester_tenant_id,
|
|
)
|
|
|
|
def delete_ticket_comment(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
comment_id: int,
|
|
requester_id: str,
|
|
requester_tenant_id: str,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
can_manage_comments: bool = False,
|
|
) -> dict[str, int | bool]:
|
|
comment = self._get_comment(
|
|
db,
|
|
ticket_id,
|
|
comment_id,
|
|
workspace_code,
|
|
abc_feedback_id,
|
|
)
|
|
self._assert_comment_author(
|
|
comment,
|
|
requester_id,
|
|
requester_tenant_id,
|
|
can_manage_comments,
|
|
)
|
|
|
|
comment.deleted_at = now_support_timestamp()
|
|
db.commit()
|
|
|
|
return {
|
|
"deleted": True,
|
|
"ticket_id": ticket_id,
|
|
"comment_id": comment_id,
|
|
}
|
|
|
|
def approve_ticket(self, db: Session, ticket_id: int) -> TicketMutationResponse:
|
|
ticket = self._get_ticket(db, ticket_id)
|
|
ticket.approval_status = "APPROVED"
|
|
ticket.status_code = "APPROVED"
|
|
ticket.sync_status = ticket.sync_status or "PENDING"
|
|
|
|
db.add(
|
|
RequestApproval(
|
|
ticket_id=ticket.id,
|
|
approver_id="ops-demo",
|
|
approver_tenant_id="baron",
|
|
approval_status="APPROVED",
|
|
comment="운영 승인 큐에서 승인 처리되었습니다.",
|
|
approved_at=now_support_timestamp(),
|
|
),
|
|
)
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
return self._serialize_mutation(ticket)
|
|
|
|
def create_issue(self, db: Session, ticket_id: int) -> TicketMutationResponse:
|
|
ticket = self._get_ticket(db, ticket_id)
|
|
ticket.issue_link_status = "LINKED"
|
|
ticket.sync_status = "SYNCED"
|
|
|
|
if ticket.feedback_mapping:
|
|
ticket.feedback_mapping.sync_status = "SYNCED"
|
|
ticket.feedback_mapping.abc_feedback_url = f"ABC-{ticket.id}"
|
|
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
return self._serialize_mutation(ticket)
|
|
|
|
def sync_issue_link_status(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
linked: bool,
|
|
workspace_code: str | None = None,
|
|
) -> dict[str, int | str | bool]:
|
|
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
ticket.issue_link_status = "LINKED" if linked else "NOT_LINKED"
|
|
if linked:
|
|
ticket.sync_status = "SYNCED"
|
|
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
|
|
return {
|
|
"ticket_id": ticket.id,
|
|
"issue_link_status": ticket.issue_link_status,
|
|
"status_code": ticket.status_code,
|
|
"feedback_status": ticket.feedback_status,
|
|
"external_issue_status": ticket.external_issue_status,
|
|
"linked": linked,
|
|
}
|
|
|
|
def _serialize_ticket(
|
|
self,
|
|
ticket: SupportTicket,
|
|
db: Session | None = None,
|
|
) -> SupportTicketRecord:
|
|
requester_email = ticket.requester_email
|
|
requester_name = ticket.requester_name
|
|
requester_department = ticket.requester_department
|
|
requester_phone_number = ticket.requester_phone_number
|
|
|
|
if db is not None:
|
|
support_user = db.execute(
|
|
select(SupportUser).where(
|
|
SupportUser.sso_subject == ticket.requester_id,
|
|
SupportUser.tenant_id == ticket.requester_tenant_id,
|
|
),
|
|
).scalar_one_or_none()
|
|
if support_user is not None:
|
|
requester_email = requester_email or support_user.email
|
|
requester_name = requester_name or support_user.name
|
|
requester_department = requester_department or support_user.department
|
|
requester_phone_number = requester_phone_number or support_user.phone_number
|
|
|
|
activity = self._build_activity(ticket)
|
|
assignee = None
|
|
if ticket.current_assignee_id and ticket.current_assignee_tenant_id and db is not None:
|
|
assignee = db.execute(
|
|
select(SupportUser).where(
|
|
SupportUser.sso_subject == ticket.current_assignee_id,
|
|
SupportUser.tenant_id == ticket.current_assignee_tenant_id,
|
|
),
|
|
).scalar_one_or_none()
|
|
extra_fields = dict(ticket.extra_fields or {})
|
|
attachments = [
|
|
self._serialize_attachment(attachment)
|
|
for attachment in ticket.attachments
|
|
if attachment.deleted_at is None and attachment.attachment_status == "ACTIVE"
|
|
]
|
|
|
|
if ticket.feedback_mapping:
|
|
extra_fields.setdefault(
|
|
"abc_feedback_id",
|
|
ticket.feedback_mapping.abc_feedback_id,
|
|
)
|
|
if ticket.feedback_mapping.abc_feedback_url:
|
|
extra_fields.setdefault(
|
|
"abc_feedback_url",
|
|
ticket.feedback_mapping.abc_feedback_url,
|
|
)
|
|
|
|
return SupportTicketRecord(
|
|
ticket_id=ticket.id,
|
|
workspace_code=ticket.workspace.workspace_code,
|
|
workspace_name=ticket.workspace.workspace_name,
|
|
title=ticket.title,
|
|
description=ticket.description,
|
|
requester_id=ticket.requester_id,
|
|
requester_tenant_id=ticket.requester_tenant_id,
|
|
requester_contact=ticket.requester_contact or "",
|
|
requester_email=requester_email,
|
|
requester_name=requester_name,
|
|
requester_department=requester_department,
|
|
requester_phone_number=requester_phone_number,
|
|
category_code=ticket.category_code or "GENERAL",
|
|
ticket_type=ticket.ticket_type,
|
|
requires_approval=ticket.requires_approval,
|
|
status_code=ticket.status_code,
|
|
feedback_status=ticket.feedback_status,
|
|
approval_status=ticket.approval_status,
|
|
sync_status=ticket.sync_status,
|
|
issue_link_status=ticket.issue_link_status,
|
|
external_issue_status=ticket.external_issue_status,
|
|
assignee_id=ticket.current_assignee_id,
|
|
assignee_tenant_id=ticket.current_assignee_tenant_id,
|
|
assignee_name=assignee.name if assignee else None,
|
|
assignee_email=assignee.email if assignee else None,
|
|
is_secret=ticket.is_secret,
|
|
created_at=serialize_support_timestamp(ticket.created_at),
|
|
updated_at=serialize_support_timestamp(ticket.updated_at),
|
|
extra_fields=extra_fields,
|
|
attachments=attachments,
|
|
activity=activity,
|
|
)
|
|
|
|
def _serialize_attachment(self, attachment: Attachment) -> SupportAttachmentRecord:
|
|
return SupportAttachmentRecord(
|
|
attachment_id=attachment.id,
|
|
original_file_name=attachment.original_file_name,
|
|
mime_type=attachment.mime_type,
|
|
file_size=attachment.file_size,
|
|
created_at=serialize_support_timestamp(attachment.created_at),
|
|
)
|
|
|
|
def _serialize_mutation(self, ticket: SupportTicket) -> TicketMutationResponse:
|
|
return TicketMutationResponse(
|
|
ticket_id=ticket.id,
|
|
status_code=ticket.status_code,
|
|
feedback_status=ticket.feedback_status,
|
|
approval_status=ticket.approval_status,
|
|
issue_link_status=ticket.issue_link_status,
|
|
external_issue_status=ticket.external_issue_status,
|
|
updated_at=serialize_support_timestamp(ticket.updated_at),
|
|
)
|
|
|
|
def _serialize_comment(
|
|
self,
|
|
comment: TicketComment,
|
|
requester_id: str | None = None,
|
|
requester_tenant_id: str | None = None,
|
|
can_manage_comments: bool = False,
|
|
) -> SupportTicketCommentRecord:
|
|
is_author = (
|
|
requester_id is not None
|
|
and requester_tenant_id is not None
|
|
and comment.author_id == requester_id
|
|
and comment.author_tenant_id == requester_tenant_id
|
|
)
|
|
return SupportTicketCommentRecord(
|
|
comment_id=comment.id,
|
|
ticket_id=comment.ticket_id,
|
|
author_id=comment.author_id,
|
|
author_tenant_id=comment.author_tenant_id,
|
|
author_name=comment.author_name or comment.author_id,
|
|
content=comment.content,
|
|
is_internal=comment.is_internal,
|
|
comment_type=comment.comment_type,
|
|
created_at=serialize_support_timestamp(comment.created_at),
|
|
updated_at=serialize_support_timestamp(comment.updated_at),
|
|
edited_at=serialize_support_timestamp(comment.edited_at),
|
|
attachments=[
|
|
self._serialize_attachment(attachment)
|
|
for attachment in comment.attachments
|
|
if attachment.deleted_at is None
|
|
and attachment.attachment_status == "ACTIVE"
|
|
],
|
|
can_edit=is_author,
|
|
can_delete=is_author or can_manage_comments,
|
|
)
|
|
|
|
def _build_activity(self, ticket: SupportTicket) -> list[SupportActivityItem]:
|
|
items = [
|
|
SupportActivityItem(
|
|
id=f"received-{ticket.id}",
|
|
label="접수",
|
|
detail="사용자가 지원 요청을 등록했습니다.",
|
|
timestamp=serialize_support_timestamp(ticket.created_at),
|
|
),
|
|
]
|
|
|
|
if ticket.approval_status == "PENDING":
|
|
items.insert(
|
|
0,
|
|
SupportActivityItem(
|
|
id=f"approval-pending-{ticket.id}",
|
|
label="승인 대기",
|
|
detail="승인형 요청으로 분류되어 승인 큐에 등록되었습니다.",
|
|
timestamp=serialize_support_timestamp(ticket.updated_at),
|
|
),
|
|
)
|
|
if ticket.approval_status == "APPROVED":
|
|
items.insert(
|
|
0,
|
|
SupportActivityItem(
|
|
id=f"approved-{ticket.id}",
|
|
label="승인 완료",
|
|
detail="운영 승인 큐에서 승인 처리되었습니다.",
|
|
timestamp=serialize_support_timestamp(ticket.updated_at),
|
|
),
|
|
)
|
|
if ticket.issue_link_status == "LINKED":
|
|
items.insert(
|
|
0,
|
|
SupportActivityItem(
|
|
id=f"linked-{ticket.id}",
|
|
label="이슈 연결",
|
|
detail="관리자 화면에서 이슈 연결이 완료되었습니다.",
|
|
timestamp=serialize_support_timestamp(ticket.updated_at),
|
|
),
|
|
)
|
|
|
|
return items
|
|
|
|
def _get_workspace_by_code(self, db: Session, workspace_code: str, raise_not_found: bool = True) -> Workspace | None:
|
|
workspace = db.execute(
|
|
select(Workspace).where(Workspace.workspace_code == workspace_code),
|
|
).scalar_one_or_none()
|
|
|
|
if not workspace and raise_not_found:
|
|
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
|
|
|
|
return workspace
|
|
|
|
def _resolve_workspace_target(
|
|
self,
|
|
db: Session,
|
|
workspace_code: str,
|
|
) -> ABCProjectTarget:
|
|
workspace_mapping_service.sync_missing_mappings(db, [workspace_code])
|
|
mapping = (
|
|
db.execute(
|
|
select(WorkspaceChannelMapping)
|
|
.join(Workspace)
|
|
.where(
|
|
Workspace.workspace_code == workspace_code,
|
|
WorkspaceChannelMapping.is_active.is_(True),
|
|
WorkspaceChannelMapping.abc_project_id.is_not(None),
|
|
)
|
|
.order_by(WorkspaceChannelMapping.id.asc()),
|
|
)
|
|
.scalars()
|
|
.first()
|
|
)
|
|
if mapping is None or mapping.abc_project_id is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=(
|
|
f"ABC 프로젝트 매핑이 설정되지 않은 workspace입니다: "
|
|
f"{workspace_code}"
|
|
),
|
|
)
|
|
|
|
try:
|
|
project_id = int(mapping.abc_project_id)
|
|
channel_id = int(mapping.abc_channel_id)
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"ABC 프로젝트 매핑 값이 올바르지 않습니다: {workspace_code}",
|
|
) from exc
|
|
|
|
if project_id <= 0 or channel_id <= 0:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"ABC 프로젝트 매핑 값이 올바르지 않습니다: {workspace_code}",
|
|
)
|
|
|
|
return ABCProjectTarget(
|
|
project_id=project_id,
|
|
channel_id=channel_id,
|
|
)
|
|
|
|
def _resolve_api_key(self, target: ABCProjectTarget) -> str:
|
|
return target.api_key or settings.abc_api_key
|
|
|
|
def _sanitize_path_segment(self, value: str) -> str:
|
|
sanitized = re.sub(r'[\\/:*?"<>|]+', '_', value).strip()
|
|
sanitized = re.sub(r'\s+', ' ', sanitized)
|
|
return sanitized or 'unnamed'
|
|
|
|
def _sanitize_file_name(self, file_name: str) -> str:
|
|
base_name = Path(file_name).name.strip() or "attachment.bin"
|
|
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", base_name)
|
|
return sanitized or "attachment.bin"
|
|
|
|
def _get_ticket(self, db: Session, ticket_id: int) -> SupportTicket:
|
|
ticket = db.execute(
|
|
select(SupportTicket)
|
|
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
|
|
.where(SupportTicket.id == ticket_id),
|
|
).unique().scalar_one_or_none()
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
return ticket
|
|
|
|
def _get_comment(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
comment_id: int,
|
|
workspace_code: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> TicketComment:
|
|
ticket = self._resolve_ticket_for_external_id(
|
|
db,
|
|
ticket_id,
|
|
workspace_code,
|
|
abc_feedback_id=abc_feedback_id,
|
|
)
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
|
|
|
comment = db.execute(
|
|
select(TicketComment).where(
|
|
TicketComment.id == comment_id,
|
|
TicketComment.ticket_id == ticket.id,
|
|
TicketComment.deleted_at.is_(None),
|
|
),
|
|
).scalar_one_or_none()
|
|
|
|
if not comment:
|
|
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
|
|
|
return comment
|
|
|
|
def _resolve_ticket_for_external_id(
|
|
self,
|
|
db: Session,
|
|
external_ticket_id: int,
|
|
workspace_code: str | None = None,
|
|
requester_id: str | None = None,
|
|
requester_tenant_id: str | None = None,
|
|
abc_feedback_id: int | None = None,
|
|
) -> SupportTicket | None:
|
|
if abc_feedback_id is not None:
|
|
mapped_statement = (
|
|
select(SupportTicket)
|
|
.join(ABCFeedbackMapping, ABCFeedbackMapping.ticket_id == SupportTicket.id)
|
|
.options(
|
|
joinedload(SupportTicket.workspace),
|
|
joinedload(SupportTicket.feedback_mapping),
|
|
joinedload(SupportTicket.approvals),
|
|
joinedload(SupportTicket.attachments),
|
|
)
|
|
.where(
|
|
ABCFeedbackMapping.abc_feedback_id == str(abc_feedback_id),
|
|
SupportTicket.sync_status != "DELETED",
|
|
)
|
|
)
|
|
if workspace_code:
|
|
mapped_statement = mapped_statement.join(Workspace).where(
|
|
Workspace.workspace_code == workspace_code,
|
|
)
|
|
|
|
mapped_ticket = db.execute(mapped_statement).unique().scalar_one_or_none()
|
|
if mapped_ticket:
|
|
return mapped_ticket
|
|
|
|
# Legacy rows may have been created before abc_feedback_mappings
|
|
# existed. Reuse an unmapped ticket with the same local id, but do
|
|
# not reuse a ticket that is explicitly mapped to another ABC id.
|
|
legacy_statement = (
|
|
select(SupportTicket)
|
|
.options(
|
|
joinedload(SupportTicket.workspace),
|
|
joinedload(SupportTicket.feedback_mapping),
|
|
joinedload(SupportTicket.approvals),
|
|
joinedload(SupportTicket.attachments),
|
|
)
|
|
.where(
|
|
SupportTicket.id == external_ticket_id,
|
|
SupportTicket.sync_status != "DELETED",
|
|
)
|
|
)
|
|
if workspace_code:
|
|
legacy_statement = legacy_statement.join(Workspace).where(
|
|
Workspace.workspace_code == workspace_code,
|
|
)
|
|
legacy_ticket = db.execute(legacy_statement).unique().scalar_one_or_none()
|
|
if legacy_ticket and (
|
|
legacy_ticket.feedback_mapping is None
|
|
or legacy_ticket.feedback_mapping.abc_feedback_id == str(abc_feedback_id)
|
|
):
|
|
return legacy_ticket
|
|
|
|
if workspace_code:
|
|
migrated_ticket = self._resolve_migrated_ticket_from_abc_feedback(
|
|
db,
|
|
abc_feedback_id,
|
|
workspace_code,
|
|
)
|
|
if migrated_ticket:
|
|
return migrated_ticket
|
|
|
|
return self._provision_ticket_from_abc_feedback(
|
|
db,
|
|
abc_feedback_id,
|
|
workspace_code,
|
|
requester_id,
|
|
requester_tenant_id,
|
|
)
|
|
|
|
direct_ticket = db.execute(
|
|
select(SupportTicket)
|
|
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
|
|
.where(
|
|
SupportTicket.id == external_ticket_id,
|
|
SupportTicket.sync_status != "DELETED",
|
|
),
|
|
).unique().scalar_one_or_none()
|
|
|
|
if direct_ticket:
|
|
return direct_ticket
|
|
|
|
mapped_ticket = db.execute(
|
|
select(SupportTicket)
|
|
.join(ABCFeedbackMapping, ABCFeedbackMapping.ticket_id == SupportTicket.id)
|
|
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
|
|
.where(
|
|
ABCFeedbackMapping.abc_feedback_id == str(external_ticket_id),
|
|
SupportTicket.sync_status != "DELETED",
|
|
),
|
|
).unique().scalar_one_or_none()
|
|
|
|
if mapped_ticket:
|
|
return mapped_ticket
|
|
|
|
if not workspace_code:
|
|
return None
|
|
|
|
return self._provision_ticket_from_abc_feedback(
|
|
db,
|
|
external_ticket_id,
|
|
workspace_code,
|
|
requester_id,
|
|
requester_tenant_id,
|
|
)
|
|
|
|
def _resolve_migrated_ticket_from_abc_feedback(
|
|
self,
|
|
db: Session,
|
|
abc_feedback_id: int,
|
|
workspace_code: str,
|
|
) -> SupportTicket | None:
|
|
"""Resolve an imported EGBIM post before creating a duplicate ticket.
|
|
|
|
Imported ABC feedback IDs are generated by the ABC database and do not
|
|
equal the legacy EGBIM post IDs. The imported feedback JSON carries the
|
|
legacy post ID, which is linked to support_tickets through
|
|
baron_support.migration_mappings.
|
|
"""
|
|
|
|
try:
|
|
feedback = self._fetch_abc_feedback(db, workspace_code, abc_feedback_id)
|
|
except HTTPException:
|
|
return None
|
|
|
|
raw_data = feedback.get("data")
|
|
if isinstance(raw_data, str):
|
|
try:
|
|
raw_data = json.loads(raw_data)
|
|
except json.JSONDecodeError:
|
|
raw_data = None
|
|
|
|
source_post_id = None
|
|
if isinstance(raw_data, dict):
|
|
source_post_id = raw_data.get("source_post_id")
|
|
if source_post_id is None:
|
|
source_post_id = feedback.get("source_post_id")
|
|
if source_post_id is None:
|
|
return None
|
|
|
|
mapping = db.execute(
|
|
sql_text(
|
|
"""
|
|
SELECT mm.ticket_id
|
|
FROM migration_mappings mm
|
|
JOIN workspaces w ON w.id = mm.workspace_id
|
|
WHERE mm.source_system = :source_system
|
|
AND mm.source_entity_type = 'POST'
|
|
AND mm.source_entity_id = :source_entity_id
|
|
AND mm.workspace_id = w.id
|
|
AND w.workspace_code = :workspace_code
|
|
AND mm.ticket_id IS NOT NULL
|
|
AND mm.migration_status <> 'FAILED'
|
|
ORDER BY mm.id DESC
|
|
LIMIT 1
|
|
""",
|
|
),
|
|
{
|
|
"source_system": "EGBIM_QA",
|
|
"source_entity_id": str(source_post_id),
|
|
"workspace_code": workspace_code,
|
|
},
|
|
).mappings().first()
|
|
|
|
if not mapping or mapping["ticket_id"] is None:
|
|
return None
|
|
|
|
statement = (
|
|
select(SupportTicket)
|
|
.options(
|
|
joinedload(SupportTicket.workspace),
|
|
joinedload(SupportTicket.feedback_mapping),
|
|
joinedload(SupportTicket.approvals),
|
|
joinedload(SupportTicket.attachments),
|
|
)
|
|
.where(
|
|
SupportTicket.id == int(mapping["ticket_id"]),
|
|
SupportTicket.sync_status != "DELETED",
|
|
)
|
|
)
|
|
return db.execute(statement).unique().scalar_one_or_none()
|
|
|
|
def _provision_ticket_from_abc_feedback(
|
|
self,
|
|
db: Session,
|
|
abc_feedback_id: int,
|
|
workspace_code: str,
|
|
requester_id: str | None,
|
|
requester_tenant_id: str | None,
|
|
) -> SupportTicket | None:
|
|
workspace = self._get_workspace_by_code(db, workspace_code, raise_not_found=False)
|
|
|
|
if not workspace:
|
|
return None
|
|
|
|
target = self._resolve_workspace_target(db, workspace_code)
|
|
feedback = self._fetch_abc_feedback(db, workspace_code, abc_feedback_id)
|
|
title = feedback.get("title") if isinstance(feedback.get("title"), str) else f"ABC Feedback {abc_feedback_id}"
|
|
description = (
|
|
feedback.get("contents") if isinstance(feedback.get("contents"), str)
|
|
else feedback.get("message") if isinstance(feedback.get("message"), str)
|
|
else ""
|
|
)
|
|
|
|
ticket = SupportTicket(
|
|
workspace_id=workspace.id,
|
|
requester_id=requester_id or "legacy-abc-user",
|
|
requester_tenant_id=requester_tenant_id or "baron",
|
|
requester_contact="",
|
|
ticket_type="GENERAL",
|
|
source_system="ABC",
|
|
title=title,
|
|
description=description,
|
|
category_code=self._default_category_code(workspace_code),
|
|
status_code="RECEIVED",
|
|
approval_status="NOT_REQUIRED",
|
|
sync_status="SYNCED",
|
|
issue_link_status="NOT_LINKED",
|
|
requires_approval=False,
|
|
extra_fields={
|
|
"abc_feedback_id": str(abc_feedback_id),
|
|
},
|
|
)
|
|
db.add(ticket)
|
|
db.flush()
|
|
|
|
mapping = ABCFeedbackMapping(
|
|
ticket_id=ticket.id,
|
|
workspace_id=workspace.id,
|
|
abc_channel_id=str(target.channel_id),
|
|
abc_feedback_id=str(abc_feedback_id),
|
|
abc_feedback_url=(
|
|
f"/main/project/{target.project_id}/feedback?channelId={target.channel_id}"
|
|
),
|
|
sync_status="SYNCED",
|
|
)
|
|
db.add(mapping)
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
|
|
return ticket
|
|
|
|
def _fetch_abc_feedback(
|
|
self,
|
|
db: Session,
|
|
workspace_code: str,
|
|
feedback_id: int,
|
|
) -> dict[str, object]:
|
|
target = self._resolve_workspace_target(db, workspace_code)
|
|
api_key = self._resolve_api_key(target)
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
|
|
|
|
endpoint = (
|
|
f"{settings.abc_api_base_url}/api/v2/projects/"
|
|
f"{target.project_id}/channels/{target.channel_id}/feedbacks/search"
|
|
)
|
|
payload = json.dumps({"query": {}}).encode("utf-8")
|
|
req = request.Request(
|
|
endpoint,
|
|
headers={
|
|
"x-api-key": api_key,
|
|
"Content-Type": "application/json",
|
|
},
|
|
data=payload,
|
|
method="POST",
|
|
)
|
|
|
|
try:
|
|
with request.urlopen(req, timeout=10) as response:
|
|
payload_data = json.loads(response.read().decode("utf-8"))
|
|
except error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise HTTPException(status_code=502, detail=f"ABC feedback fetch failed: {detail}") from exc
|
|
except error.URLError as exc:
|
|
raise HTTPException(status_code=502, detail=f"ABC API connection failed: {exc.reason}") from exc
|
|
|
|
if not isinstance(payload_data, dict):
|
|
raise HTTPException(status_code=502, detail="ABC API returned an invalid feedback payload.")
|
|
|
|
items = payload_data.get("items")
|
|
if not isinstance(items, list):
|
|
raise HTTPException(status_code=502, detail="ABC API returned an invalid feedback search payload.")
|
|
|
|
feedback = next(
|
|
(
|
|
item
|
|
for item in items
|
|
if isinstance(item, dict) and int(item.get("id", -1)) == feedback_id
|
|
),
|
|
None,
|
|
)
|
|
|
|
if not isinstance(feedback, dict):
|
|
raise HTTPException(status_code=404, detail="ABC 피드백을 찾을 수 없습니다.")
|
|
|
|
return feedback
|
|
|
|
def get_ticket_attachment(
|
|
self,
|
|
db: Session,
|
|
ticket_id: int,
|
|
attachment_id: int,
|
|
workspace_code: str | None = None,
|
|
) -> Attachment:
|
|
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
|
|
|
attachment = db.execute(
|
|
select(Attachment).where(
|
|
Attachment.id == attachment_id,
|
|
Attachment.ticket_id == ticket.id,
|
|
Attachment.deleted_at.is_(None),
|
|
Attachment.attachment_status == "ACTIVE",
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
if not attachment:
|
|
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다.")
|
|
|
|
return attachment
|
|
|
|
def resolve_attachment_path(self, attachment: Attachment) -> Path:
|
|
if attachment.storage_provider.upper() == "R2":
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="R2 첨부파일은 파일 경로로 조회할 수 없습니다.",
|
|
)
|
|
storage_bucket = attachment.storage_bucket or settings.upload_root_dir
|
|
file_path = Path(storage_bucket) / attachment.storage_key
|
|
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다.")
|
|
|
|
return file_path
|
|
|
|
def read_attachment(self, attachment: Attachment) -> bytes:
|
|
if attachment.storage_provider.upper() == "R2":
|
|
try:
|
|
return storage_service.get_bytes(
|
|
attachment.storage_key,
|
|
attachment.storage_bucket,
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="R2 첨부파일을 찾을 수 없습니다.",
|
|
) from exc
|
|
|
|
return self.resolve_attachment_path(attachment).read_bytes()
|
|
|
|
def _assert_comment_author(
|
|
self,
|
|
comment: TicketComment,
|
|
requester_id: str,
|
|
requester_tenant_id: str,
|
|
allow_any_author: bool = False,
|
|
) -> None:
|
|
if allow_any_author:
|
|
return
|
|
if (
|
|
comment.author_id != requester_id
|
|
or comment.author_tenant_id != requester_tenant_id
|
|
):
|
|
raise HTTPException(status_code=403, detail="본인이 작성한 댓글만 수정 또는 삭제할 수 있습니다.")
|
|
|
|
def _default_category_code(self, workspace_code: str) -> str:
|
|
defaults = {
|
|
"INTRA_BOOK_REQUEST": "BOOK",
|
|
"Q&A_Platform": "GENERAL_QNA",
|
|
"INTRA_SUPPLIES_REQUEST": "SUPPLIES",
|
|
"INTRA_VEHICLE_REQUEST": "VEHICLE",
|
|
"INTRA_EQUIPMENT_RENTAL": "EQUIPMENT",
|
|
}
|
|
return defaults.get(workspace_code, "GENERAL_QNA")
|
|
|
|
def _resolve_category_code(
|
|
self,
|
|
workspace_code: str,
|
|
category_code: str | None,
|
|
) -> str:
|
|
normalized = (category_code or "").strip().upper()
|
|
|
|
if not normalized or normalized == "GENERAL":
|
|
return self._default_category_code(workspace_code)
|
|
|
|
if workspace_code in {"Q&A_Platform", "EGBIM_DEMO"} and normalized not in QNA_CATEGORY_CODES:
|
|
return "GENERAL_QNA"
|
|
|
|
return normalized
|