first commit
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s

This commit is contained in:
SDI
2026-07-15 18:05:12 +09:00
commit 12e4f17b62
4633 changed files with 817125 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""secretary-api application package."""
+1
View File
@@ -0,0 +1 @@
"""API routing package."""
+9
View File
@@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.api.routes.health import router as health_router
from app.api.routes.tickets import router as workspace_ticket_router, ticket_router
api_router = APIRouter()
api_router.include_router(health_router, prefix="/api")
api_router.include_router(workspace_ticket_router, prefix="/api")
api_router.include_router(ticket_router, prefix="/api")
@@ -0,0 +1 @@
"""API route modules."""
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
@@ -0,0 +1,187 @@
import json
from typing import cast
from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.datastructures import UploadFile as StarletteUploadFile
from app.db.session import get_db
from app.schemas.ticket import (
SupportTicketCommentRecord,
SupportTicketRecord,
TicketCommentCreateRequest,
TicketIssueLinkStatusSyncRequest,
TicketCommentUpdateRequest,
TicketCreateRequest,
TicketCreateResponse,
TicketMutationResponse,
WorkspaceFormTemplateResponse,
WorkspaceSummary,
)
from app.services.ticket_service import TicketService
router = APIRouter(prefix="/workspaces", tags=["tickets"])
service = TicketService()
@router.get("", response_model=list[WorkspaceSummary])
def list_workspaces(db: Session = Depends(get_db)) -> list[WorkspaceSummary]:
return service.list_workspaces(db)
@router.get("/{workspace_code}/form-template", response_model=WorkspaceFormTemplateResponse)
def get_workspace_form_template(
workspace_code: str,
db: Session = Depends(get_db),
) -> WorkspaceFormTemplateResponse:
return service.get_workspace_form_template(db, workspace_code)
@router.post("/{workspace_code}/tickets", response_model=TicketCreateResponse)
async def create_ticket(
workspace_code: str,
request: Request,
db: Session = Depends(get_db),
) -> TicketCreateResponse:
content_type = request.headers.get("content-type", "")
if content_type.startswith("multipart/form-data"):
form = await request.form()
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,
requester_id=str(form.get("requester_id") or ""),
requester_tenant_id=str(form.get("requester_tenant_id") or ""),
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"),
requires_approval=str(form.get("requires_approval") or "false").lower() == "true",
extra_fields=extra_fields,
)
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})
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,
db: Session = Depends(get_db),
) -> list[SupportTicketRecord]:
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"])
@ticket_router.get("", response_model=list[SupportTicketRecord])
def list_tickets(db: Session = Depends(get_db)) -> list[SupportTicketRecord]:
return service.list_tickets(db)
@ticket_router.get("/{ticket_id}", response_model=SupportTicketRecord)
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}")
def download_ticket_attachment(
ticket_id: int,
attachment_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> FileResponse:
attachment = service.get_ticket_attachment(db, ticket_id, attachment_id, workspaceCode)
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.get("/{ticket_id}/comments", response_model=list[SupportTicketCommentRecord])
def list_ticket_comments(
ticket_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> list[SupportTicketCommentRecord]:
return service.list_ticket_comments(db, ticket_id, workspaceCode)
@ticket_router.post("/{ticket_id}/comments", response_model=SupportTicketCommentRecord)
def create_ticket_comment(
ticket_id: int,
payload: TicketCommentCreateRequest,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
return service.create_ticket_comment(db, ticket_id, payload, workspaceCode)
@ticket_router.put("/{ticket_id}/comments/{comment_id}", response_model=SupportTicketCommentRecord)
def update_ticket_comment(
ticket_id: int,
comment_id: int,
payload: TicketCommentUpdateRequest,
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
return service.update_ticket_comment(db, ticket_id, comment_id, payload)
@ticket_router.delete("/{ticket_id}/comments/{comment_id}")
def delete_ticket_comment(
ticket_id: int,
comment_id: int,
requesterId: str,
requesterTenantId: str,
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_ticket_comment(db, ticket_id, comment_id, requesterId, requesterTenantId)
@ticket_router.post("/{ticket_id}/approve", response_model=TicketMutationResponse)
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)
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")
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)
+1
View File
@@ -0,0 +1 @@
"""Core configuration and shared services."""
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
REPO_ROOT = Path(__file__).resolve().parents[4]
DEFAULT_UPLOAD_ROOT = REPO_ROOT / "uploads"
class Settings(BaseSettings):
app_name: str = "secretary-api"
app_env: str = "local"
app_host: str = "0.0.0.0"
app_port: int = 8010
database_url: str = "mysql+pymysql://baron_support:baron_support@127.0.0.1:13308/baron_support"
abc_api_base_url: str = "http://localhost:4000"
abc_api_key: str = ""
abc_project_id: int = 1
abc_channel_id: int = 1
upload_root_dir: str = str(DEFAULT_UPLOAD_ROOT)
upload_max_file_size_mb: int = 30
sso_issuer: str = ""
sso_client_id: str = ""
sso_client_secret: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()
+1
View File
@@ -0,0 +1 @@
"""Database package."""
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, Boolean, ForeignKey, String, Text, TIMESTAMP, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Workspace(Base):
__tablename__ = "workspaces"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_type: Mapped[str] = mapped_column(String(20))
software_app_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
service_type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
workspace_code: Mapped[str] = mapped_column(String(50), unique=True)
workspace_name: Mapped[str] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
tickets: Mapped[list[SupportTicket]] = relationship(back_populates="workspace")
approval_policies: Mapped[list[WorkspaceApprovalPolicy]] = relationship(back_populates="workspace")
attachments: Mapped[list[Attachment]] = relationship(back_populates="workspace")
class SupportTicket(Base):
__tablename__ = "support_tickets"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
requester_id: Mapped[str] = mapped_column(String(100))
requester_tenant_id: Mapped[str] = mapped_column(String(100))
requester_contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
ticket_type: Mapped[str] = mapped_column(String(30))
source_system: Mapped[str] = mapped_column(String(30), server_default=text("'ABC'"))
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text)
category_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
status_code: Mapped[str] = mapped_column(String(20))
approval_status: Mapped[str] = mapped_column(String(20), server_default=text("'NOT_REQUIRED'"))
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'PENDING'"))
issue_link_status: Mapped[str] = mapped_column(String(20), server_default=text("'NOT_REQUIRED'"))
is_secret: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
requires_approval: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
current_assignee_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
current_assignee_tenant_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
requested_start_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
requested_end_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
priority: Mapped[str] = mapped_column(String(20), server_default=text("'NORMAL'"))
extra_fields: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
workspace: Mapped[Workspace] = relationship(back_populates="tickets")
feedback_mapping: Mapped[ABCFeedbackMapping | None] = relationship(back_populates="ticket", uselist=False)
approvals: Mapped[list[RequestApproval]] = relationship(back_populates="ticket")
comments: Mapped[list[TicketComment]] = relationship(back_populates="ticket")
attachments: Mapped[list[Attachment]] = relationship(back_populates="ticket")
class ABCFeedbackMapping(Base):
__tablename__ = "abc_feedback_mappings"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"), unique=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
abc_channel_id: Mapped[str] = mapped_column(String(100))
abc_feedback_id: Mapped[str] = mapped_column(String(100), unique=True)
abc_feedback_url: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'SYNCED'"))
last_synced_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
ticket: Mapped[SupportTicket] = relationship(back_populates="feedback_mapping")
class RequestApproval(Base):
__tablename__ = "request_approvals"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
workflow_policy_id: Mapped[int | None] = mapped_column(ForeignKey("workspace_approval_policies.id"), nullable=True)
workflow_step_id: Mapped[int | None] = mapped_column(ForeignKey("workspace_approval_steps.id"), nullable=True)
step_order: Mapped[int | None] = mapped_column(nullable=True)
approver_id: Mapped[str] = mapped_column(String(100))
approver_tenant_id: Mapped[str] = mapped_column(String(100))
approval_status: Mapped[str] = mapped_column(String(20))
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
ticket: Mapped[SupportTicket] = relationship(back_populates="approvals")
workflow_policy: Mapped[WorkspaceApprovalPolicy | None] = relationship(back_populates="approval_history")
workflow_step: Mapped[WorkspaceApprovalStep | None] = relationship(back_populates="approval_history")
class WorkspaceApprovalPolicy(Base):
__tablename__ = "workspace_approval_policies"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
request_category_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
ticket_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
rule_name: Mapped[str] = mapped_column(String(100))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
conditions: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
version: Mapped[int] = mapped_column(server_default=text("1"))
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
workspace: Mapped[Workspace] = relationship(back_populates="approval_policies")
steps: Mapped[list[WorkspaceApprovalStep]] = relationship(back_populates="policy")
approval_history: Mapped[list[RequestApproval]] = relationship(back_populates="workflow_policy")
class WorkspaceApprovalStep(Base):
__tablename__ = "workspace_approval_steps"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
policy_id: Mapped[int] = mapped_column(ForeignKey("workspace_approval_policies.id"))
step_order: Mapped[int] = mapped_column()
approver_type: Mapped[str] = mapped_column(String(30))
approver_key: Mapped[str] = mapped_column(String(100))
approver_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
approval_mode: Mapped[str] = mapped_column(String(20), server_default=text("'ALL'"))
is_required: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
sla_hours: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
policy: Mapped[WorkspaceApprovalPolicy] = relationship(back_populates="steps")
approval_history: Mapped[list[RequestApproval]] = relationship(back_populates="workflow_step")
class TicketComment(Base):
__tablename__ = "ticket_comments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
parent_comment_id: Mapped[int | None] = mapped_column(ForeignKey("ticket_comments.id"), nullable=True)
author_id: Mapped[str] = mapped_column(String(100))
author_tenant_id: Mapped[str] = mapped_column(String(100))
author_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
comment_type: Mapped[str] = mapped_column(String(20), server_default=text("'COMMENT'"))
content: Mapped[str] = mapped_column(Text)
is_internal: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
abc_comment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'PENDING'"))
edited_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
ticket: Mapped[SupportTicket] = relationship(back_populates="comments")
parent_comment: Mapped[TicketComment | None] = relationship(remote_side=[id])
attachments: Mapped[list[Attachment]] = relationship(back_populates="comment")
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
comment_id: Mapped[int | None] = mapped_column(ForeignKey("ticket_comments.id"), nullable=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
uploader_id: Mapped[str] = mapped_column(String(100))
uploader_tenant_id: Mapped[str] = mapped_column(String(100))
original_file_name: Mapped[str] = mapped_column(String(255))
stored_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
storage_provider: Mapped[str] = mapped_column(String(30), server_default=text("'LOCAL'"))
storage_bucket: Mapped[str | None] = mapped_column(String(255), nullable=True)
storage_key: Mapped[str] = mapped_column(String(500))
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
file_extension: Mapped[str | None] = mapped_column(String(20), nullable=True)
file_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
abc_attachment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
attachment_status: Mapped[str] = mapped_column(String(20), server_default=text("'ACTIVE'"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
ticket: Mapped[SupportTicket] = relationship(back_populates="attachments")
comment: Mapped[TicketComment | None] = relationship(back_populates="attachments")
workspace: Mapped[Workspace] = relationship(back_populates="attachments")
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Session:
database = SessionLocal()
try:
yield database
finally:
database.close()
+13
View File
@@ -0,0 +1,13 @@
from fastapi import FastAPI
from app.api.router import api_router
from app.core.config import settings
def create_app() -> FastAPI:
application = FastAPI(title=settings.app_name)
application.include_router(api_router)
return application
app = create_app()
@@ -0,0 +1 @@
"""Pydantic schemas for secretary-api."""
+119
View File
@@ -0,0 +1,119 @@
from pydantic import BaseModel, Field
class TicketCreateRequest(BaseModel):
workspace_code: str = Field(default="", max_length=50)
requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
requester_contact: str | None = Field(default=None, max_length=100)
title: str = Field(..., min_length=1, max_length=255)
description: str = Field(..., min_length=1)
category_code: str | None = Field(default=None, max_length=20)
ticket_type: str = Field(default="GENERAL")
requires_approval: bool = False
extra_fields: dict[str, str] = Field(default_factory=dict)
class TicketCreateResponse(BaseModel):
ticket_id: int
workspace_code: str
status_code: str
sync_status: str
message: str
class WorkspaceSummary(BaseModel):
workspace_code: str
workspace_name: str
workspace_type: str
class WorkspaceFormField(BaseModel):
field_code: str
label: str
field_type: str
required: bool = False
class WorkspaceFormTemplateResponse(BaseModel):
workspace_code: str
workspace_name: str
requires_approval: bool
fields: list[WorkspaceFormField]
class SupportActivityItem(BaseModel):
id: str
label: str
detail: str
timestamp: str
class SupportAttachmentRecord(BaseModel):
attachment_id: int
original_file_name: str
mime_type: str | None = None
file_size: int | None = None
created_at: str
class TicketCommentCreateRequest(BaseModel):
requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
author_name: str | None = Field(default=None, max_length=100)
content: str = Field(..., min_length=1)
is_internal: bool = False
class TicketCommentUpdateRequest(BaseModel):
requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
content: str = Field(..., min_length=1)
class TicketIssueLinkStatusSyncRequest(BaseModel):
linked: bool
class SupportTicketCommentRecord(BaseModel):
comment_id: int
ticket_id: int
author_id: str
author_tenant_id: str
author_name: str
content: str
is_internal: bool
created_at: str
updated_at: str
edited_at: str | None
class SupportTicketRecord(BaseModel):
ticket_id: int
workspace_code: str
workspace_name: str
title: str
description: str
requester_id: str
requester_tenant_id: str
requester_contact: str
category_code: str
ticket_type: str
requires_approval: bool
status_code: str
approval_status: str
sync_status: str
issue_link_status: str
created_at: str
updated_at: str
extra_fields: dict[str, str]
attachments: list[SupportAttachmentRecord] = Field(default_factory=list)
activity: list[SupportActivityItem]
class TicketMutationResponse(BaseModel):
ticket_id: int
status_code: str
approval_status: str
issue_link_status: str
updated_at: str
@@ -0,0 +1 @@
"""Service layer for secretary-api."""
@@ -0,0 +1,861 @@
from __future__ import annotations
from datetime import datetime, UTC
import hashlib
import json
from pathlib import Path
import re
from uuid import uuid4
from urllib import error, request
from fastapi import HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.db.models import ABCFeedbackMapping, Attachment, RequestApproval, SupportTicket, TicketComment, Workspace
from app.schemas.ticket import (
SupportAttachmentRecord,
SupportTicketCommentRecord,
SupportActivityItem,
SupportTicketRecord,
TicketCommentCreateRequest,
TicketCommentUpdateRequest,
TicketCreateRequest,
TicketCreateResponse,
TicketMutationResponse,
WorkspaceFormField,
WorkspaceFormTemplateResponse,
WorkspaceSummary,
)
class TicketService:
_workspace_targets = {
"INTRA_BOOK_REQUEST": {
"project_id": 2,
"channel_id": 2,
"project_name": "project-2",
"channel_name": "channel-2",
},
"INTRA_GENERAL_QNA": {
"project_id": 1,
"channel_id": 1,
"project_name": "CRUD Check Project",
"channel_name": "CRUD Channel",
},
}
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)
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),
],
),
"INTRA_GENERAL_QNA": WorkspaceFormTemplateResponse(
workspace_code="INTRA_GENERAL_QNA",
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="environment", label="환경", field_type="text"),
WorkspaceFormField(field_code="error_message", label="오류 메시지", field_type="textarea"),
WorkspaceFormField(field_code="expected_result", label="기대 결과", field_type="textarea"),
],
),
}
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 "",
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,
approval_status=approval_status,
sync_status="PENDING",
issue_link_status=issue_link_status,
requires_approval=payload.requires_approval,
extra_fields=payload.extra_fields,
)
db.add(ticket)
db.flush()
abc_feedback_id = self._create_abc_feedback(payload)
ticket.sync_status = "SYNCED"
target = self._resolve_workspace_target(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,
sync_status=ticket.sync_status,
message="ABC 피드백과 내부 티켓이 함께 저장되었습니다."
)
def _create_abc_feedback(self, payload: TicketCreateRequest) -> int:
if not settings.abc_api_key:
raise HTTPException(
status_code=500,
detail="ABC API key is not configured.",
)
target = self._resolve_workspace_target(payload.workspace_code)
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": settings.abc_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 _save_ticket_attachments(
self,
db: Session,
workspace: Workspace,
ticket: SupportTicket,
payload: TicketCreateRequest,
attachments: list[UploadFile],
) -> None:
target = self._resolve_workspace_target(payload.workspace_code)
upload_root = Path(settings.upload_root_dir)
relative_directory = self._build_attachment_directory(target)
absolute_directory = upload_root / relative_directory
absolute_directory.mkdir(parents=True, exist_ok=True)
saved_file_paths: list[Path] = []
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}"
absolute_path = absolute_directory / stored_file_name
absolute_path.write_bytes(file_bytes)
saved_file_paths.append(absolute_path)
db.add(
Attachment(
ticket_id=ticket.id,
workspace_id=workspace.id,
uploader_id=payload.requester_id,
uploader_tenant_id=payload.requester_tenant_id,
original_file_name=original_file_name,
stored_file_name=stored_file_name,
storage_provider="LOCAL",
storage_bucket=str(upload_root),
storage_key=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()
raise
def _build_attachment_directory(self, target: dict[str, int | str]) -> Path:
project_id = target["project_id"]
channel_id = target["channel_id"]
project_name = self._sanitize_path_segment(
str(target.get("project_name") or f"project-{project_id}")
)
channel_name = self._sanitize_path_segment(
str(target.get("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)
.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) for ticket in tickets]
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))
.order_by(SupportTicket.id.desc()),
).unique().scalars().all()
return [self._serialize_ticket(ticket) 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)
def list_ticket_comments(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
) -> list[SupportTicketCommentRecord]:
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
if not ticket:
return []
comments = db.execute(
select(TicketComment)
.where(TicketComment.ticket_id == ticket.id, TicketComment.deleted_at.is_(None))
.order_by(TicketComment.created_at.asc(), TicketComment.id.asc()),
).scalars().all()
return [self._serialize_comment(comment) for comment in comments]
def create_ticket_comment(
self,
db: Session,
ticket_id: int,
payload: TicketCommentCreateRequest,
workspace_code: str | None = None,
) -> 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,
)
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,
sync_status="LOCAL_ONLY",
)
db.add(comment)
db.commit()
db.refresh(comment)
return self._serialize_comment(comment)
def update_ticket_comment(
self,
db: Session,
ticket_id: int,
comment_id: int,
payload: TicketCommentUpdateRequest,
) -> SupportTicketCommentRecord:
comment = self._get_comment(db, ticket_id, comment_id)
self._assert_comment_author(comment, payload.requester_id, payload.requester_tenant_id)
comment.content = payload.content.strip()
comment.edited_at = datetime.now(UTC)
db.commit()
db.refresh(comment)
return self._serialize_comment(comment)
def delete_ticket_comment(
self,
db: Session,
ticket_id: int,
comment_id: int,
requester_id: str,
requester_tenant_id: str,
) -> dict[str, int | bool]:
comment = self._get_comment(db, ticket_id, comment_id)
self._assert_comment_author(comment, requester_id, requester_tenant_id)
comment.deleted_at = datetime.now(UTC)
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=datetime.now(UTC),
),
)
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.status_code = "IN_PROGRESS"
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.status_code = "IN_PROGRESS"
ticket.sync_status = "SYNCED"
elif ticket.status_code == "IN_PROGRESS":
ticket.status_code = "RECEIVED"
db.commit()
db.refresh(ticket)
return {
"ticket_id": ticket.id,
"issue_link_status": ticket.issue_link_status,
"status_code": ticket.status_code,
"linked": linked,
}
def _serialize_ticket(self, ticket: SupportTicket) -> SupportTicketRecord:
activity = self._build_activity(ticket)
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 "",
category_code=ticket.category_code or "GENERAL",
ticket_type=ticket.ticket_type,
requires_approval=ticket.requires_approval,
status_code=ticket.status_code,
approval_status=ticket.approval_status,
sync_status=ticket.sync_status,
issue_link_status=ticket.issue_link_status,
created_at=ticket.created_at.isoformat(),
updated_at=ticket.updated_at.isoformat(),
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=attachment.created_at.isoformat(),
)
def _serialize_mutation(self, ticket: SupportTicket) -> TicketMutationResponse:
return TicketMutationResponse(
ticket_id=ticket.id,
status_code=ticket.status_code,
approval_status=ticket.approval_status,
issue_link_status=ticket.issue_link_status,
updated_at=ticket.updated_at.isoformat(),
)
def _serialize_comment(self, comment: TicketComment) -> SupportTicketCommentRecord:
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,
created_at=comment.created_at.isoformat(),
updated_at=comment.updated_at.isoformat(),
edited_at=comment.edited_at.isoformat() if comment.edited_at else None,
)
def _build_activity(self, ticket: SupportTicket) -> list[SupportActivityItem]:
items = [
SupportActivityItem(
id=f"received-{ticket.id}",
label="접수",
detail="사용자가 지원 요청을 등록했습니다.",
timestamp=ticket.created_at.isoformat(),
),
]
if ticket.approval_status == "PENDING":
items.insert(
0,
SupportActivityItem(
id=f"approval-pending-{ticket.id}",
label="승인 대기",
detail="승인형 요청으로 분류되어 승인 큐에 등록되었습니다.",
timestamp=ticket.updated_at.isoformat(),
),
)
if ticket.approval_status == "APPROVED":
items.insert(
0,
SupportActivityItem(
id=f"approved-{ticket.id}",
label="승인 완료",
detail="운영 승인 큐에서 승인 처리되었습니다.",
timestamp=ticket.updated_at.isoformat(),
),
)
if ticket.issue_link_status == "LINKED":
items.insert(
0,
SupportActivityItem(
id=f"linked-{ticket.id}",
label="이슈 연결",
detail="관리자 화면에서 이슈 연결이 완료되었습니다.",
timestamp=ticket.updated_at.isoformat(),
),
)
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, workspace_code: str) -> dict[str, int | str]:
return self._workspace_targets.get(
workspace_code,
{
"project_id": settings.abc_project_id,
"channel_id": settings.abc_channel_id,
"project_name": f"project-{settings.abc_project_id}",
"channel_name": f"channel-{settings.abc_channel_id}",
},
)
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) -> TicketComment:
ticket = self._resolve_ticket_for_external_id(db, ticket_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,
) -> SupportTicket | None:
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),
).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)),
).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 _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
feedback = self._fetch_abc_feedback(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(settings.abc_channel_id),
abc_feedback_id=str(abc_feedback_id),
abc_feedback_url=(
f"/main/project/{settings.abc_project_id}/feedback?channelId={settings.abc_channel_id}"
),
sync_status="SYNCED",
)
db.add(mapping)
db.commit()
db.refresh(ticket)
return ticket
def _fetch_abc_feedback(self, feedback_id: int) -> dict[str, object]:
if not settings.abc_api_key:
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
endpoint = (
f"{settings.abc_api_base_url}/api/projects/"
f"{settings.abc_project_id}/channels/{settings.abc_channel_id}/feedbacks/{feedback_id}"
)
req = request.Request(
endpoint,
headers={
"x-api-key": settings.abc_api_key,
},
method="GET",
)
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.")
return payload_data
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:
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 _assert_comment_author(
self,
comment: TicketComment,
requester_id: str,
requester_tenant_id: str,
) -> None:
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",
"INTRA_GENERAL_QNA": "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)
return normalized