first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""secretary-api application package."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
"""API routing package."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -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."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core configuration and shared services."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Database package."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -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."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user