This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -0,0 +1,595 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from urllib.parse import quote
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import SsoPrincipal, get_principal
|
||||
from app.db.models import (
|
||||
SupportRole,
|
||||
SupportRoleAssignment,
|
||||
SupportUser,
|
||||
UserWorkspaceAccess,
|
||||
Workspace,
|
||||
WorkspaceChannelMapping,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.core.config import ABCProjectTarget, settings
|
||||
from app.services.access_service import AccessService
|
||||
from app.services.workspace_mapping_service import workspace_mapping_service
|
||||
|
||||
router = APIRouter(prefix="/access", tags=["access"])
|
||||
service = AccessService()
|
||||
|
||||
|
||||
def abc_project_target(
|
||||
db: Session,
|
||||
workspace_id: int,
|
||||
) -> ABCProjectTarget | None:
|
||||
mapping = (
|
||||
db.query(WorkspaceChannelMapping)
|
||||
.filter(
|
||||
WorkspaceChannelMapping.workspace_id == workspace_id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
WorkspaceChannelMapping.abc_project_id.is_not(None),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc())
|
||||
.first()
|
||||
)
|
||||
if mapping is None or mapping.abc_project_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
project_id = int(mapping.abc_project_id)
|
||||
channel_id = int(mapping.abc_channel_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if project_id <= 0 or channel_id <= 0:
|
||||
return None
|
||||
|
||||
return ABCProjectTarget(project_id=project_id, channel_id=channel_id)
|
||||
|
||||
|
||||
def candidate_tenant_ids(value: str | None = None) -> list[str]:
|
||||
raw_value = settings.admin_candidate_tenant_id if value is None else value
|
||||
return [
|
||||
tenant_id.strip()
|
||||
for tenant_id in raw_value.split(",")
|
||||
if tenant_id.strip()
|
||||
]
|
||||
|
||||
|
||||
def candidate_scope_filter(tenant_ids: str | list[str]):
|
||||
configured_tenant_ids = (
|
||||
candidate_tenant_ids(tenant_ids)
|
||||
if isinstance(tenant_ids, str)
|
||||
else tenant_ids
|
||||
)
|
||||
return or_(
|
||||
*[
|
||||
condition
|
||||
for tenant_id in configured_tenant_ids
|
||||
for condition in (
|
||||
SupportUser.tenant_id == tenant_id,
|
||||
func.json_contains(
|
||||
func.coalesce(SupportUser.tenant_ids, "[]"),
|
||||
func.json_quote(tenant_id),
|
||||
) == 1,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def normalize_phone_number(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
digits = "".join(character for character in value if character.isdigit())
|
||||
if digits.startswith("82") and len(digits) >= 11:
|
||||
return "0" + digits[2:]
|
||||
return digits
|
||||
|
||||
|
||||
def is_initial_super_admin(principal: SsoPrincipal) -> bool:
|
||||
configured_phone = normalize_phone_number(
|
||||
settings.initial_super_admin_phone_number,
|
||||
)
|
||||
return bool(configured_phone) and normalize_phone_number(
|
||||
principal.phone_number,
|
||||
) == configured_phone
|
||||
|
||||
|
||||
def is_candidate_tenant_user(principal: SsoPrincipal) -> bool:
|
||||
configured_tenant_ids = candidate_tenant_ids()
|
||||
return bool(configured_tenant_ids) and any(
|
||||
principal.tenant_id == candidate_tenant_id
|
||||
or candidate_tenant_id in principal.tenant_ids
|
||||
for candidate_tenant_id in configured_tenant_ids
|
||||
)
|
||||
|
||||
|
||||
def has_global_admin(db: Session) -> bool:
|
||||
return (
|
||||
db.query(SupportRoleAssignment)
|
||||
.join(SupportUser)
|
||||
.filter(
|
||||
SupportUser.status == "ACTIVE",
|
||||
SupportRoleAssignment.role_code.in_(service.GLOBAL_ADMIN_ROLES),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def can_manage_admins(db: Session, principal: SsoPrincipal) -> bool:
|
||||
service.ensure_support_user(db, principal)
|
||||
db.commit()
|
||||
if service.is_system_admin(db, principal):
|
||||
return True
|
||||
return not has_global_admin(db) and is_initial_super_admin(principal) and is_candidate_tenant_user(principal)
|
||||
|
||||
|
||||
|
||||
class WorkspaceAccessRequest(BaseModel):
|
||||
user_id: str = Field(min_length=1, max_length=100)
|
||||
tenant_id: str = Field(min_length=1, max_length=100)
|
||||
workspace_role: str = Field(default="END_USER", max_length=30)
|
||||
can_read: bool = True
|
||||
can_write: bool = False
|
||||
can_manage: bool = False
|
||||
can_approve: bool = False
|
||||
page_scope: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class AdminAssignmentRequest(BaseModel):
|
||||
user_id: str = Field(min_length=1, max_length=100)
|
||||
tenant_id: str = Field(min_length=1, max_length=100)
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
name: str | None = Field(default=None, max_length=100)
|
||||
department: str | None = Field(default=None, max_length=100)
|
||||
phone_number: str | None = Field(default=None, max_length=50)
|
||||
tenant_ids: list[str] | None = None
|
||||
role_code: str = Field(min_length=1, max_length=40)
|
||||
workspace_code: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_my_access(
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
workspace_mapping_service.sync_project_workspaces(db)
|
||||
workspace_mapping_service.sync_missing_mappings(db)
|
||||
entries = service.list_accessible_workspaces(db, principal)
|
||||
is_system_admin = service.is_system_admin(db, principal)
|
||||
support_entries = [
|
||||
(workspace, access)
|
||||
for workspace, access in entries
|
||||
if access.can_read
|
||||
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
]
|
||||
if not support_entries and not is_system_admin:
|
||||
entries = service.ensure_default_support_access(db, principal)
|
||||
|
||||
manager_entries = [
|
||||
(workspace, access)
|
||||
for workspace, access in entries
|
||||
if access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
or access.can_manage
|
||||
]
|
||||
support_entries = [
|
||||
(workspace, access)
|
||||
for workspace, access in entries
|
||||
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
]
|
||||
|
||||
default_admin_path = None
|
||||
admin_workspace_code = None
|
||||
for workspace, access in manager_entries:
|
||||
target = abc_project_target(db, workspace.id)
|
||||
if admin_workspace_code is None:
|
||||
admin_workspace_code = workspace.workspace_code
|
||||
if target is None:
|
||||
continue
|
||||
admin_workspace_code = workspace.workspace_code
|
||||
default_admin_path = (
|
||||
f"/main/project/{target.project_id}/feedback"
|
||||
f"?channelId={target.channel_id}"
|
||||
)
|
||||
break
|
||||
|
||||
default_support_path = None
|
||||
default_support_create_path = None
|
||||
if support_entries:
|
||||
workspace_code = support_entries[0][0].workspace_code
|
||||
encoded_workspace_code = quote(workspace_code, safe='')
|
||||
default_support_path = f"/support/{encoded_workspace_code}/list"
|
||||
default_support_create_path = f"/support/{encoded_workspace_code}/new"
|
||||
|
||||
return {
|
||||
"user_id": principal.user_id,
|
||||
"tenant_id": principal.tenant_id,
|
||||
"email": principal.email,
|
||||
"name": principal.name,
|
||||
"department": principal.department,
|
||||
"phone_number": principal.phone_number,
|
||||
"is_system_admin": is_system_admin,
|
||||
"is_admin": bool(manager_entries),
|
||||
"roles": sorted({access.workspace_role for _, access in entries}),
|
||||
"workspaces": [
|
||||
{
|
||||
"workspace_code": workspace.workspace_code,
|
||||
"workspace_name": workspace.workspace_name,
|
||||
"workspace_role": access.workspace_role,
|
||||
"can_read": access.can_read,
|
||||
"can_write": access.can_write,
|
||||
"can_manage": access.can_manage,
|
||||
"can_approve": access.can_approve,
|
||||
"page_scope": access.page_scope,
|
||||
"abc_project_id": (
|
||||
abc_project_target(db, workspace.id).project_id
|
||||
if abc_project_target(db, workspace.id)
|
||||
else None
|
||||
),
|
||||
"abc_channel_id": (
|
||||
abc_project_target(db, workspace.id).channel_id
|
||||
if abc_project_target(db, workspace.id)
|
||||
else None
|
||||
),
|
||||
}
|
||||
for workspace, access in entries
|
||||
],
|
||||
"default_admin_path": default_admin_path,
|
||||
"admin_workspace_code": admin_workspace_code,
|
||||
"default_support_path": default_support_path,
|
||||
"default_support_create_path": default_support_create_path,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_code}/users")
|
||||
def upsert_workspace_access(
|
||||
workspace_code: str,
|
||||
payload: WorkspaceAccessRequest,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
if not service.is_system_admin(db, principal):
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
|
||||
workspace = db.query(Workspace).filter_by(
|
||||
workspace_code=workspace_code,
|
||||
is_active=True,
|
||||
).one_or_none()
|
||||
if workspace is None:
|
||||
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
|
||||
|
||||
if payload.workspace_role not in service.SUPPORTED_WORKSPACE_ROLES:
|
||||
raise HTTPException(status_code=400, detail="지원하지 않는 workspace 역할입니다.")
|
||||
|
||||
support_user = db.query(SupportUser).filter_by(
|
||||
sso_subject=payload.user_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
).one_or_none()
|
||||
if support_user is None:
|
||||
support_user = SupportUser(
|
||||
sso_subject=payload.user_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
)
|
||||
db.add(support_user)
|
||||
db.flush()
|
||||
|
||||
assignment = db.query(SupportRoleAssignment).filter_by(
|
||||
support_user_id=support_user.id,
|
||||
workspace_id=workspace.id,
|
||||
).filter(SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES)).first()
|
||||
if assignment is None:
|
||||
assignment = SupportRoleAssignment(
|
||||
support_user_id=support_user.id,
|
||||
workspace_id=workspace.id,
|
||||
role_code=payload.workspace_role,
|
||||
)
|
||||
db.add(assignment)
|
||||
else:
|
||||
assignment.role_code = payload.workspace_role
|
||||
|
||||
access = db.query(UserWorkspaceAccess).filter_by(
|
||||
user_id=payload.user_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
).one_or_none()
|
||||
if access is None:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=payload.user_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
db.add(access)
|
||||
|
||||
service._apply_role_permissions(access, payload.workspace_role)
|
||||
access.page_scope = payload.page_scope
|
||||
db.commit()
|
||||
db.refresh(access)
|
||||
return {
|
||||
"id": access.id,
|
||||
"user_id": access.user_id,
|
||||
"tenant_id": access.tenant_id,
|
||||
"workspace_code": workspace.workspace_code,
|
||||
"workspace_role": access.workspace_role,
|
||||
}
|
||||
|
||||
@router.get("/candidates")
|
||||
def list_admin_candidates(
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
if not can_manage_admins(db, principal):
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
configured_tenant_ids = candidate_tenant_ids()
|
||||
users_query = db.query(SupportUser).filter(SupportUser.status == "ACTIVE")
|
||||
if configured_tenant_ids:
|
||||
users_query = users_query.filter(candidate_scope_filter(configured_tenant_ids))
|
||||
else:
|
||||
# System administrators are already authorized to manage the current
|
||||
# tenant. Do not make the assignment dialog unusable just because the
|
||||
# optional initial-admin candidate scope is not configured. The POST
|
||||
# endpoint applies the same current-tenant boundary when this setting
|
||||
# is empty.
|
||||
if not service.is_system_admin(db, principal):
|
||||
return {"items": []}
|
||||
users_query = users_query.filter(SupportUser.tenant_id == principal.tenant_id)
|
||||
|
||||
users = users_query.order_by(SupportUser.id.asc()).all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"email": user.email or user.sso_subject,
|
||||
"isRegistered": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"department": user.department,
|
||||
"phone_number": user.phone_number,
|
||||
"oauthSubject": user.sso_subject,
|
||||
"oauthTenantId": user.tenant_id,
|
||||
"tenantIds": user.tenant_ids or [],
|
||||
},
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admins")
|
||||
def list_admin_assignments(
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
if not can_manage_admins(db, principal):
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
|
||||
project_workspace_codes = workspace_mapping_service.sync_project_workspaces(db)
|
||||
roles = db.query(SupportRole).order_by(SupportRole.code.asc()).all()
|
||||
workspace_query = db.query(Workspace).filter(
|
||||
Workspace.is_active.is_(True),
|
||||
Workspace.workspace_type == "SOFTWARE_APP",
|
||||
)
|
||||
# Limit the selector to projects currently returned by ABC. This avoids
|
||||
# exposing legacy software_app seed rows that are not ABC projects.
|
||||
if project_workspace_codes is not None:
|
||||
workspace_query = workspace_query.filter(
|
||||
Workspace.workspace_code.in_(project_workspace_codes),
|
||||
)
|
||||
workspaces = workspace_query.order_by(Workspace.id.asc()).all()
|
||||
configured_tenant_ids = candidate_tenant_ids()
|
||||
assignment_scope = (
|
||||
candidate_scope_filter(configured_tenant_ids)
|
||||
if configured_tenant_ids
|
||||
else SupportUser.tenant_id == principal.tenant_id
|
||||
)
|
||||
assignments = (
|
||||
db.query(SupportRoleAssignment)
|
||||
.join(SupportUser)
|
||||
.filter(
|
||||
SupportUser.status == "ACTIVE",
|
||||
assignment_scope,
|
||||
SupportRoleAssignment.role_code.in_(
|
||||
service.GLOBAL_ADMIN_ROLES | {"PROJECT_MANAGER", "FEEDBACK_PROVIDER"},
|
||||
),
|
||||
)
|
||||
.order_by(SupportRoleAssignment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
users: dict[int, dict[str, object]] = {}
|
||||
for assignment in assignments:
|
||||
user = assignment.user
|
||||
item = users.setdefault(
|
||||
user.id,
|
||||
{
|
||||
"id": user.id,
|
||||
"user_id": user.sso_subject,
|
||||
"tenant_id": user.tenant_id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"department": user.department,
|
||||
"phone_number": user.phone_number,
|
||||
"assignments": [],
|
||||
},
|
||||
)
|
||||
item["assignments"].append(
|
||||
{
|
||||
"id": assignment.id,
|
||||
"role_code": assignment.role_code,
|
||||
"role_name": assignment.role.name if assignment.role else assignment.role_code,
|
||||
"workspace_id": assignment.workspace_id,
|
||||
"workspace_code": assignment.workspace.workspace_code if assignment.workspace else None,
|
||||
"workspace_name": assignment.workspace.workspace_name if assignment.workspace else None,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"roles": [
|
||||
{
|
||||
"code": role.code,
|
||||
"name": role.name,
|
||||
"description": role.description,
|
||||
}
|
||||
for role in roles
|
||||
],
|
||||
"workspaces": [
|
||||
{
|
||||
"id": workspace.id,
|
||||
"code": workspace.workspace_code,
|
||||
"name": workspace.workspace_name,
|
||||
}
|
||||
for workspace in workspaces
|
||||
],
|
||||
"users": list(users.values()),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admins")
|
||||
def upsert_admin_assignment(
|
||||
payload: AdminAssignmentRequest,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
if not can_manage_admins(db, principal):
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
|
||||
configured_tenant_ids = candidate_tenant_ids()
|
||||
if configured_tenant_ids:
|
||||
target_user = db.query(SupportUser).filter_by(
|
||||
sso_subject=payload.user_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
status="ACTIVE",
|
||||
).one_or_none()
|
||||
if target_user is None or not (
|
||||
target_user.tenant_id in configured_tenant_ids
|
||||
or any(tenant_id in (target_user.tenant_ids or []) for tenant_id in configured_tenant_ids)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="지정된 후보 테넌트의 사용자만 관리할 수 있습니다.")
|
||||
elif payload.tenant_id != principal.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="현재 tenant의 사용자만 관리할 수 있습니다.")
|
||||
|
||||
role_code = payload.role_code.upper()
|
||||
if role_code not in service.ROLE_PERMISSIONS:
|
||||
raise HTTPException(status_code=400, detail="지원하지 않는 역할입니다.")
|
||||
|
||||
workspace = None
|
||||
if role_code in service.GLOBAL_ADMIN_ROLES:
|
||||
if payload.workspace_code:
|
||||
raise HTTPException(status_code=400, detail="전체 관리자 역할에는 workspace를 지정할 수 없습니다.")
|
||||
else:
|
||||
if not payload.workspace_code:
|
||||
raise HTTPException(status_code=400, detail="workspace 역할에는 workspace가 필요합니다.")
|
||||
workspace = db.query(Workspace).filter_by(
|
||||
workspace_code=payload.workspace_code,
|
||||
is_active=True,
|
||||
).one_or_none()
|
||||
if workspace is None:
|
||||
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
|
||||
|
||||
user = service.ensure_support_user_identity(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
email=payload.email,
|
||||
name=payload.name,
|
||||
department=payload.department,
|
||||
phone_number=payload.phone_number,
|
||||
tenant_ids=payload.tenant_ids,
|
||||
)
|
||||
if workspace is not None:
|
||||
existing_workspace_assignments = db.query(SupportRoleAssignment).filter(
|
||||
SupportRoleAssignment.support_user_id == user.id,
|
||||
SupportRoleAssignment.workspace_id == workspace.id,
|
||||
SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES),
|
||||
).all()
|
||||
for existing in existing_workspace_assignments:
|
||||
if existing.role_code != role_code:
|
||||
db.delete(existing)
|
||||
|
||||
assignment = db.query(SupportRoleAssignment).filter_by(
|
||||
support_user_id=user.id,
|
||||
role_code=role_code,
|
||||
workspace_id=workspace.id if workspace else None,
|
||||
).one_or_none()
|
||||
if assignment is None:
|
||||
assignment = SupportRoleAssignment(
|
||||
support_user_id=user.id,
|
||||
role_code=role_code,
|
||||
workspace_id=workspace.id if workspace else None,
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
if workspace is not None:
|
||||
access = db.query(UserWorkspaceAccess).filter_by(
|
||||
user_id=user.sso_subject,
|
||||
tenant_id=user.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
).one_or_none()
|
||||
if access is None:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=user.sso_subject,
|
||||
tenant_id=user.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
db.add(access)
|
||||
service._apply_role_permissions(access, role_code)
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
return {
|
||||
"id": assignment.id,
|
||||
"user_id": user.sso_subject,
|
||||
"tenant_id": user.tenant_id,
|
||||
"role_code": role_code,
|
||||
"workspace_code": workspace.workspace_code if workspace else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/admins/{assignment_id}")
|
||||
def delete_admin_assignment(
|
||||
assignment_id: int,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
if not service.is_system_admin(db, principal):
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
|
||||
assignment = db.query(SupportRoleAssignment).filter_by(id=assignment_id).one_or_none()
|
||||
if assignment is None:
|
||||
raise HTTPException(status_code=404, detail="권한 할당을 찾을 수 없습니다.")
|
||||
if assignment.role_code in service.GLOBAL_ADMIN_ROLES:
|
||||
remaining = db.query(SupportRoleAssignment).filter(
|
||||
SupportRoleAssignment.role_code.in_(service.GLOBAL_ADMIN_ROLES),
|
||||
SupportRoleAssignment.id != assignment.id,
|
||||
).count()
|
||||
if remaining == 0:
|
||||
raise HTTPException(status_code=400, detail="전체 관리자는 최소 1명 이상 유지해야 합니다.")
|
||||
|
||||
workspace_id = assignment.workspace_id
|
||||
support_user_id = assignment.support_user_id
|
||||
db.delete(assignment)
|
||||
|
||||
if workspace_id is not None:
|
||||
remaining_role = db.query(SupportRoleAssignment).filter(
|
||||
SupportRoleAssignment.support_user_id == support_user_id,
|
||||
SupportRoleAssignment.workspace_id == workspace_id,
|
||||
SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES),
|
||||
).order_by(SupportRoleAssignment.created_at.desc()).first()
|
||||
access = db.query(UserWorkspaceAccess).filter_by(
|
||||
workspace_id=workspace_id,
|
||||
user_id=assignment.user.sso_subject,
|
||||
tenant_id=assignment.user.tenant_id,
|
||||
).one_or_none()
|
||||
if access is not None:
|
||||
if remaining_role is None:
|
||||
db.delete(access)
|
||||
else:
|
||||
service._apply_role_permissions(access, remaining_role.role_code)
|
||||
|
||||
db.commit()
|
||||
return {"deleted": True}
|
||||
@@ -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,74 @@
|
||||
from fastapi import APIRouter, Header, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import (
|
||||
SupportRoleAssignment,
|
||||
SupportUser,
|
||||
Workspace,
|
||||
WorkspaceChannelMapping,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from fastapi import Depends
|
||||
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
@router.get("/notification-recipients")
|
||||
def list_notification_recipients(
|
||||
project_id: int = Query(gt=0),
|
||||
channel_id: int = Query(gt=0),
|
||||
api_key: str | None = Header(default=None, alias="x-api-key"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
"""Return project-scoped notification recipients to ABC API only.
|
||||
|
||||
Secretary is the source of truth for workspace/project administrators.
|
||||
This endpoint is intentionally protected by the service API key and is
|
||||
not exposed through the public user-facing API contract.
|
||||
"""
|
||||
if not settings.master_api_key or api_key != settings.master_api_key:
|
||||
raise HTTPException(status_code=401, detail="internal service authentication failed")
|
||||
|
||||
mapping = (
|
||||
db.query(WorkspaceChannelMapping)
|
||||
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
|
||||
.filter(
|
||||
WorkspaceChannelMapping.abc_project_id == project_id,
|
||||
WorkspaceChannelMapping.abc_channel_id == str(channel_id),
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
Workspace.is_active.is_(True),
|
||||
Workspace.workspace_type == "SOFTWARE_APP",
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.desc())
|
||||
.first()
|
||||
)
|
||||
if mapping is None:
|
||||
return {"items": []}
|
||||
|
||||
assignments = (
|
||||
db.query(SupportRoleAssignment)
|
||||
.join(SupportUser)
|
||||
.filter(
|
||||
SupportRoleAssignment.workspace_id == mapping.workspace_id,
|
||||
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
|
||||
SupportRoleAssignment.support_user_id == SupportUser.id,
|
||||
SupportUser.status == "ACTIVE",
|
||||
SupportUser.email.is_not(None),
|
||||
)
|
||||
.order_by(SupportRoleAssignment.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
seen: set[str] = set()
|
||||
for assignment in assignments:
|
||||
email = (assignment.user.email or "").strip()
|
||||
normalized = email.lower()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
items.append({"email": email, "role": "PROJECT_ADMIN"})
|
||||
|
||||
return {"items": items}
|
||||
@@ -0,0 +1,803 @@
|
||||
import json
|
||||
from typing import Annotated, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.core.auth import SsoPrincipal, get_principal
|
||||
from app.db.session import get_db
|
||||
from app.services.access_service import AccessService
|
||||
from app.schemas.ticket import (
|
||||
SupportTicketCommentRecord,
|
||||
SupportTicketRecord,
|
||||
TicketCommentCreateRequest,
|
||||
TicketIssueLinkStatusSyncRequest,
|
||||
TicketAssigneeCandidate,
|
||||
TicketAssigneeUpdateRequest,
|
||||
InternalMemoCreateRequest,
|
||||
InternalMemoUpdateRequest,
|
||||
TicketCommentUpdateRequest,
|
||||
TicketCreateRequest,
|
||||
TicketCreateResponse,
|
||||
SupportFeedbackMetrics,
|
||||
SupportFeedbackStatusRecord,
|
||||
FeedbackStatusUpdateRequest,
|
||||
TicketUpdateRequest,
|
||||
TicketMutationResponse,
|
||||
TicketDeleteResponse,
|
||||
WorkspaceFormTemplateResponse,
|
||||
WorkspaceSummary,
|
||||
)
|
||||
from app.services.ticket_service import TicketService
|
||||
from app.services.workspace_mapping_service import workspace_mapping_service
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["tickets"])
|
||||
service = TicketService()
|
||||
access_service = AccessService()
|
||||
|
||||
|
||||
def can_manage_ticket_comments(
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
ticket_id: int,
|
||||
workspace_code: str | None,
|
||||
abc_feedback_id: int | None,
|
||||
) -> bool:
|
||||
ticket = service._resolve_ticket_for_external_id(
|
||||
db,
|
||||
ticket_id,
|
||||
workspace_code,
|
||||
abc_feedback_id=abc_feedback_id,
|
||||
)
|
||||
if ticket is None:
|
||||
return False
|
||||
|
||||
access = access_service.assert_workspace_access(
|
||||
db,
|
||||
principal,
|
||||
ticket.workspace.workspace_code,
|
||||
)
|
||||
return access.can_manage or access.workspace_role in {
|
||||
"SYSTEM_ADMIN",
|
||||
"SUPER_ADMIN",
|
||||
"PROJECT_MANAGER",
|
||||
}
|
||||
|
||||
|
||||
def require_ticket_access(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SsoPrincipal:
|
||||
ticket = service._resolve_ticket_for_external_id(
|
||||
db,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abc_feedback_id=abcFeedbackId,
|
||||
)
|
||||
if ticket is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
||||
|
||||
access = access_service.assert_workspace_access(
|
||||
db,
|
||||
principal,
|
||||
ticket.workspace.workspace_code,
|
||||
)
|
||||
if (
|
||||
access.can_manage
|
||||
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
):
|
||||
return principal
|
||||
if (
|
||||
ticket.requester_id != principal.user_id
|
||||
or ticket.requester_tenant_id != principal.tenant_id
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 지원 요청을 조회할 권한이 없습니다.")
|
||||
return principal
|
||||
|
||||
|
||||
def require_ticket_write_access(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SsoPrincipal:
|
||||
ticket = service._resolve_ticket_for_external_id(
|
||||
db,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abc_feedback_id=abcFeedbackId,
|
||||
)
|
||||
if ticket is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
||||
|
||||
access = access_service.assert_workspace_access(
|
||||
db,
|
||||
principal,
|
||||
ticket.workspace.workspace_code,
|
||||
write=True,
|
||||
)
|
||||
if (
|
||||
access.can_manage
|
||||
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
):
|
||||
return principal
|
||||
if (
|
||||
ticket.requester_id != principal.user_id
|
||||
or ticket.requester_tenant_id != principal.tenant_id
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 지원 요청을 변경할 권한이 없습니다.")
|
||||
return principal
|
||||
|
||||
|
||||
def require_ticket_manager_access(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SsoPrincipal:
|
||||
ticket = service._resolve_ticket_for_external_id(
|
||||
db,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abc_feedback_id=abcFeedbackId,
|
||||
)
|
||||
if ticket is None:
|
||||
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
|
||||
|
||||
access = access_service.assert_workspace_access(
|
||||
db,
|
||||
principal,
|
||||
ticket.workspace.workspace_code,
|
||||
)
|
||||
if not (
|
||||
access.can_manage
|
||||
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="프로젝트 관리자 권한이 필요합니다.")
|
||||
return principal
|
||||
|
||||
|
||||
@router.get("", response_model=list[WorkspaceSummary])
|
||||
def list_workspaces(
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[WorkspaceSummary]:
|
||||
workspace_mapping_service.sync_project_workspaces(db)
|
||||
return [
|
||||
WorkspaceSummary(
|
||||
workspace_code=workspace.workspace_code,
|
||||
workspace_name=workspace.workspace_name,
|
||||
workspace_type=workspace.workspace_type,
|
||||
)
|
||||
for workspace, access in access_service.list_accessible_workspaces(db, principal)
|
||||
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{workspace_code}/form-template", response_model=WorkspaceFormTemplateResponse)
|
||||
def get_workspace_form_template(
|
||||
workspace_code: str,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkspaceFormTemplateResponse:
|
||||
access_service.assert_workspace_access(db, principal, workspace_code)
|
||||
return service.get_workspace_form_template(db, workspace_code)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workspace_code}/tickets",
|
||||
response_model=TicketCreateResponse,
|
||||
response_description="생성된 지원 요청의 식별자와 동기화 상태",
|
||||
summary="지원 요청 등록",
|
||||
description=(
|
||||
"application/json 또는 multipart/form-data로 지원 요청을 등록합니다. "
|
||||
"multipart 요청은 title, description, category_code, ticket_type, is_secret, "
|
||||
"requires_approval, extra_fields(JSON 문자열), attachments(반복 파일 필드)를 지원합니다. "
|
||||
"작성자 식별자와 SSO 프로필은 인증 사용자 정보로 서버가 설정합니다."
|
||||
),
|
||||
openapi_extra={
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/TicketCreateRequest"},
|
||||
},
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["title", "description"],
|
||||
"properties": {
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 255},
|
||||
"description": {"type": "string", "minLength": 1},
|
||||
"category_code": {"type": "string"},
|
||||
"ticket_type": {"type": "string", "default": "GENERAL"},
|
||||
"is_secret": {"type": "boolean", "default": False},
|
||||
"requires_approval": {"type": "boolean", "default": False},
|
||||
"extra_fields": {
|
||||
"type": "string",
|
||||
"description": "동적 필드 JSON 객체를 문자열로 전달",
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "format": "binary"},
|
||||
"description": "반복 가능한 첨부파일 필드",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def create_ticket(
|
||||
workspace_code: str,
|
||||
request: Request,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TicketCreateResponse:
|
||||
access_service.assert_workspace_access(db, principal, workspace_code, write=True)
|
||||
content_type = request.headers.get("content-type", "")
|
||||
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
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"),
|
||||
is_secret=str(form.get("is_secret") or "false").lower() == "true",
|
||||
requires_approval=str(form.get("requires_approval") or "false").lower() == "true",
|
||||
extra_fields=extra_fields,
|
||||
)
|
||||
request_payload = request_payload.model_copy(update={
|
||||
"requester_id": principal.user_id,
|
||||
"requester_tenant_id": principal.tenant_id,
|
||||
"requester_email": principal.email,
|
||||
"requester_name": principal.name,
|
||||
"requester_department": principal.department,
|
||||
"requester_phone_number": principal.phone_number,
|
||||
})
|
||||
return service.create_ticket(db, request_payload, attachments)
|
||||
|
||||
payload = TicketCreateRequest.model_validate(await request.json())
|
||||
request_payload = payload.model_copy(update={
|
||||
"workspace_code": workspace_code,
|
||||
"requester_id": principal.user_id,
|
||||
"requester_tenant_id": principal.tenant_id,
|
||||
"requester_email": principal.email,
|
||||
"requester_name": principal.name,
|
||||
"requester_department": principal.department,
|
||||
"requester_phone_number": principal.phone_number,
|
||||
})
|
||||
return service.create_ticket(db, request_payload)
|
||||
|
||||
|
||||
@router.get("/{workspace_code}/tickets", response_model=list[SupportTicketRecord])
|
||||
def list_workspace_tickets(
|
||||
workspace_code: str,
|
||||
requesterId: str | None = None,
|
||||
requesterTenantId: str | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[SupportTicketRecord]:
|
||||
access = access_service.assert_workspace_access(db, principal, workspace_code)
|
||||
if access.can_manage or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}:
|
||||
requesterId = None
|
||||
requesterTenantId = None
|
||||
else:
|
||||
requesterId = principal.user_id
|
||||
requesterTenantId = principal.tenant_id
|
||||
return service.list_workspace_tickets(db, workspace_code, requesterId, requesterTenantId)
|
||||
|
||||
|
||||
@router.get("/../tickets", include_in_schema=False)
|
||||
def _noop_redirect() -> None:
|
||||
return None
|
||||
|
||||
|
||||
ticket_router = APIRouter(
|
||||
prefix="/tickets",
|
||||
tags=["support-tickets"],
|
||||
dependencies=[Depends(get_principal)],
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.get("/statuses", response_model=list[SupportFeedbackStatusRecord])
|
||||
def get_feedback_statuses(
|
||||
projectId: int,
|
||||
channelId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[SupportFeedbackStatusRecord]:
|
||||
return [
|
||||
SupportFeedbackStatusRecord.model_validate(item)
|
||||
for item in service.list_feedback_statuses(db, principal, projectId, channelId)
|
||||
]
|
||||
|
||||
|
||||
@ticket_router.get("/metrics", response_model=SupportFeedbackMetrics)
|
||||
def get_feedback_metrics(
|
||||
projectId: int,
|
||||
channelId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportFeedbackMetrics:
|
||||
return SupportFeedbackMetrics.model_validate(
|
||||
service.get_feedback_metrics(db, principal, projectId, channelId)
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.get("", response_model=list[SupportTicketRecord])
|
||||
def list_tickets(
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[SupportTicketRecord]:
|
||||
if not access_service.is_system_admin(db, principal):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
return service.list_tickets(db)
|
||||
|
||||
|
||||
@ticket_router.patch("/{ticket_id}/feedback-status", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_manager_access)])
|
||||
def update_feedback_status(
|
||||
ticket_id: int,
|
||||
payload: FeedbackStatusUpdateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TicketMutationResponse:
|
||||
return service.update_feedback_status(
|
||||
db,
|
||||
ticket_id,
|
||||
payload.feedback_status,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.get("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_access)])
|
||||
def get_ticket(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketRecord:
|
||||
return service.get_ticket(db, ticket_id, workspaceCode)
|
||||
|
||||
|
||||
@ticket_router.get("/{ticket_id}/attachments/{attachment_id}", dependencies=[Depends(require_ticket_access)])
|
||||
def download_ticket_attachment(
|
||||
ticket_id: int,
|
||||
attachment_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
attachment = service.get_ticket_attachment(db, ticket_id, attachment_id, workspaceCode)
|
||||
if attachment.storage_provider.upper() == "R2":
|
||||
return Response(
|
||||
content=service.read_attachment(attachment),
|
||||
media_type=attachment.mime_type or "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
"attachment; filename*=UTF-8''"
|
||||
+ quote(attachment.original_file_name)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
file_path = service.resolve_attachment_path(attachment)
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=attachment.mime_type or "application/octet-stream",
|
||||
filename=attachment.original_file_name,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.put("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_write_access)])
|
||||
def update_ticket(
|
||||
ticket_id: int,
|
||||
payload: TicketUpdateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketRecord:
|
||||
return service.update_ticket(db, ticket_id, payload, workspaceCode)
|
||||
|
||||
|
||||
@ticket_router.delete("/{ticket_id}", dependencies=[Depends(require_ticket_write_access)])
|
||||
def delete_ticket(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, int | bool]:
|
||||
return service.delete_ticket(db, ticket_id, workspaceCode)
|
||||
|
||||
|
||||
@ticket_router.get(
|
||||
"/{ticket_id}/assignees",
|
||||
response_model=list[TicketAssigneeCandidate],
|
||||
dependencies=[Depends(require_ticket_manager_access)],
|
||||
)
|
||||
def list_ticket_assignees(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[TicketAssigneeCandidate]:
|
||||
return service.list_assignee_candidates(
|
||||
db,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.patch(
|
||||
"/{ticket_id}/assignee",
|
||||
response_model=SupportTicketRecord,
|
||||
dependencies=[Depends(require_ticket_manager_access)],
|
||||
)
|
||||
def update_ticket_assignee(
|
||||
ticket_id: int,
|
||||
payload: TicketAssigneeUpdateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketRecord:
|
||||
return service.update_assignee(
|
||||
db,
|
||||
ticket_id,
|
||||
payload,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.get(
|
||||
"/{ticket_id}/internal-memos",
|
||||
response_model=list[SupportTicketCommentRecord],
|
||||
response_description="관리자 전용 내부 메모 목록",
|
||||
summary="내부 메모 목록 조회",
|
||||
description=(
|
||||
"관리자 권한이 있는 사용자만 조회할 수 있습니다. "
|
||||
"일반 댓글은 반환하지 않으며, 내부 메모는 사용자용 댓글 응답에 포함되지 않습니다."
|
||||
),
|
||||
tags=["internal-memos"],
|
||||
dependencies=[Depends(require_ticket_manager_access)],
|
||||
)
|
||||
def list_internal_memos(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[SupportTicketCommentRecord]:
|
||||
return service.list_internal_memos(
|
||||
db,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
principal.user_id,
|
||||
principal.tenant_id,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.post(
|
||||
"/{ticket_id}/internal-memos",
|
||||
response_model=SupportTicketCommentRecord,
|
||||
response_description="생성된 내부 메모",
|
||||
summary="내부 메모 등록",
|
||||
description="관리자 전용 메모를 등록합니다. 내부 메모는 고객용 댓글 목록에 노출되지 않습니다.",
|
||||
tags=["internal-memos"],
|
||||
dependencies=[Depends(require_ticket_manager_access)],
|
||||
)
|
||||
def create_internal_memo(
|
||||
ticket_id: int,
|
||||
payload: InternalMemoCreateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketCommentRecord:
|
||||
secured_payload = TicketCommentCreateRequest(
|
||||
requester_id=principal.user_id,
|
||||
requester_tenant_id=principal.tenant_id,
|
||||
author_name=principal.name or principal.email or principal.user_id,
|
||||
content=payload.content,
|
||||
is_internal=True,
|
||||
)
|
||||
return service.create_internal_memo(
|
||||
db,
|
||||
ticket_id,
|
||||
secured_payload,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.get(
|
||||
"/{ticket_id}/comments",
|
||||
response_model=list[SupportTicketCommentRecord],
|
||||
response_description="공개 댓글 목록",
|
||||
summary="공개 댓글 목록 조회",
|
||||
description=(
|
||||
"지원 요청의 공개 댓글만 반환합니다. 내부 메모는 관리자라도 이 경로에서는 반환하지 않고 "
|
||||
"별도의 내부 메모 API로만 조회합니다."
|
||||
),
|
||||
tags=["comments"],
|
||||
)
|
||||
def list_ticket_comments(
|
||||
ticket_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(require_ticket_access),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[SupportTicketCommentRecord]:
|
||||
can_manage_comments = can_manage_ticket_comments(
|
||||
db,
|
||||
principal,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
return service.list_ticket_comments(
|
||||
db,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
principal.user_id,
|
||||
principal.tenant_id,
|
||||
can_manage_comments,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.post(
|
||||
"/{ticket_id}/comments",
|
||||
response_model=SupportTicketCommentRecord,
|
||||
response_description="생성된 공개 댓글",
|
||||
summary="공개 댓글 등록",
|
||||
description=(
|
||||
"공개 댓글을 등록합니다. 작성자와 테넌트는 요청 본문을 신뢰하지 않고 "
|
||||
"SSO 인증 사용자 정보로 결정합니다. 관리자 전용 메모는 내부 메모 API를 사용합니다."
|
||||
),
|
||||
tags=["comments"],
|
||||
dependencies=[Depends(require_ticket_write_access)],
|
||||
)
|
||||
def create_ticket_comment(
|
||||
ticket_id: int,
|
||||
payload: TicketCommentCreateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketCommentRecord:
|
||||
secured_payload = payload.model_copy(update={
|
||||
"requester_id": principal.user_id,
|
||||
"requester_tenant_id": principal.tenant_id,
|
||||
"author_name": principal.name or principal.email or principal.user_id,
|
||||
"is_internal": False,
|
||||
})
|
||||
return service.create_ticket_comment(
|
||||
db,
|
||||
ticket_id,
|
||||
secured_payload,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.post(
|
||||
"/{ticket_id}/comments/attachments",
|
||||
response_model=SupportTicketCommentRecord,
|
||||
response_description="첨부파일이 연결된 생성 댓글",
|
||||
summary="첨부파일과 함께 공개 댓글 등록",
|
||||
description=(
|
||||
"multipart/form-data로 공개 댓글과 첨부파일을 등록합니다. "
|
||||
"content는 댓글 본문이고 attachments는 반복 가능한 파일 필드입니다. "
|
||||
"내부 메모 여부와 작성자 정보는 인증 권한으로 서버가 결정합니다."
|
||||
),
|
||||
tags=["comments", "attachments"],
|
||||
dependencies=[Depends(require_ticket_write_access)],
|
||||
)
|
||||
async def create_ticket_comment_with_attachments(
|
||||
ticket_id: int,
|
||||
content: Annotated[str, Form(description="공개 댓글 본문")] = "",
|
||||
attachments: Annotated[
|
||||
list[UploadFile] | None,
|
||||
File(description="댓글에 연결할 파일. 같은 필드명을 반복해 여러 파일을 첨부할 수 있습니다."),
|
||||
] = None,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketCommentRecord:
|
||||
payload = TicketCommentCreateRequest(
|
||||
requester_id=principal.user_id,
|
||||
requester_tenant_id=principal.tenant_id,
|
||||
author_name=principal.name or principal.email or principal.user_id,
|
||||
content=content,
|
||||
is_internal=False,
|
||||
)
|
||||
return service.create_ticket_comment(
|
||||
db,
|
||||
ticket_id,
|
||||
payload,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
attachments or [],
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.put(
|
||||
"/{ticket_id}/internal-memos/{memo_id}",
|
||||
response_model=SupportTicketCommentRecord,
|
||||
response_description="수정된 내부 메모",
|
||||
summary="내부 메모 수정",
|
||||
description="관리자 전용 내부 메모를 수정합니다.",
|
||||
tags=["internal-memos"],
|
||||
dependencies=[Depends(require_ticket_manager_access)],
|
||||
)
|
||||
def update_internal_memo(
|
||||
ticket_id: int,
|
||||
memo_id: int,
|
||||
payload: InternalMemoUpdateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketCommentRecord:
|
||||
secured_payload = TicketCommentUpdateRequest(
|
||||
requester_id=principal.user_id,
|
||||
requester_tenant_id=principal.tenant_id,
|
||||
content=payload.content,
|
||||
)
|
||||
return service.update_internal_memo(
|
||||
db,
|
||||
ticket_id,
|
||||
memo_id,
|
||||
secured_payload,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.put(
|
||||
"/{ticket_id}/comments/{comment_id}",
|
||||
response_model=SupportTicketCommentRecord,
|
||||
response_description="수정된 공개 댓글",
|
||||
summary="공개 댓글 수정",
|
||||
description="댓글 작성자 본인의 공개 댓글을 수정합니다. 내부 메모는 별도 내부 메모 API로 수정합니다.",
|
||||
tags=["comments"],
|
||||
dependencies=[Depends(require_ticket_write_access)],
|
||||
)
|
||||
def update_ticket_comment(
|
||||
ticket_id: int,
|
||||
comment_id: int,
|
||||
payload: TicketCommentUpdateRequest,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SupportTicketCommentRecord:
|
||||
secured_payload = payload.model_copy(update={
|
||||
"requester_id": principal.user_id,
|
||||
"requester_tenant_id": principal.tenant_id,
|
||||
})
|
||||
return service.update_ticket_comment(
|
||||
db,
|
||||
ticket_id,
|
||||
comment_id,
|
||||
secured_payload,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.delete(
|
||||
"/{ticket_id}/internal-memos/{memo_id}",
|
||||
response_model=TicketDeleteResponse,
|
||||
response_description="삭제 결과",
|
||||
summary="내부 메모 삭제",
|
||||
description="관리자 전용 내부 메모를 삭제합니다.",
|
||||
tags=["internal-memos"],
|
||||
dependencies=[Depends(require_ticket_manager_access)],
|
||||
)
|
||||
def delete_internal_memo(
|
||||
ticket_id: int,
|
||||
memo_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, int | bool]:
|
||||
return service.delete_internal_memo(
|
||||
db,
|
||||
ticket_id,
|
||||
memo_id,
|
||||
principal.user_id,
|
||||
principal.tenant_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.delete(
|
||||
"/{ticket_id}/comments/{comment_id}",
|
||||
response_model=TicketDeleteResponse,
|
||||
response_description="삭제 결과",
|
||||
summary="공개 댓글 삭제",
|
||||
description="댓글 작성자 본인 또는 관리자 권한으로 공개 댓글을 삭제합니다.",
|
||||
tags=["comments"],
|
||||
dependencies=[Depends(require_ticket_write_access)],
|
||||
)
|
||||
def delete_ticket_comment(
|
||||
ticket_id: int,
|
||||
comment_id: int,
|
||||
workspaceCode: str | None = None,
|
||||
abcFeedbackId: int | None = None,
|
||||
principal: SsoPrincipal = Depends(get_principal),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, int | bool]:
|
||||
return service.delete_ticket_comment(
|
||||
db,
|
||||
ticket_id,
|
||||
comment_id,
|
||||
principal.user_id,
|
||||
principal.tenant_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
can_manage_ticket_comments(
|
||||
db,
|
||||
principal,
|
||||
ticket_id,
|
||||
workspaceCode,
|
||||
abcFeedbackId,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ticket_router.post("/{ticket_id}/approve", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
|
||||
def approve_ticket(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
|
||||
return service.approve_ticket(db, ticket_id)
|
||||
|
||||
|
||||
@ticket_router.post("/{ticket_id}/issue", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
|
||||
def create_issue(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
|
||||
return service.create_issue(db, ticket_id)
|
||||
|
||||
|
||||
@ticket_router.post("/{ticket_id}/issue-link-status", dependencies=[Depends(require_ticket_write_access)])
|
||||
def sync_issue_link_status(
|
||||
ticket_id: int,
|
||||
payload: TicketIssueLinkStatusSyncRequest,
|
||||
workspaceCode: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, int | str | bool]:
|
||||
return service.sync_issue_link_status(db, ticket_id, payload.linked, workspaceCode)
|
||||
Reference in New Issue
Block a user