feat(project): 댓글·첨부·단계편집·알림센터 + 라이브러리 self-host

아사나식 기능 4종 추가:
- 댓글: 업무 모달에서 등록/삭제, 관련자 인앱 알림(task_comments)
- 첨부: 업로드(20MB)/다운로드/삭제, 파일은 DATA_DIR/project/<task_id>/,
  DB엔 메타만(task_attachments)
- 단계 편집: 보드 칸반에서 이름변경/완료토글/순서이동/삭제/추가
- 알림센터(인앱): 배정·완료·댓글 시 수신자별 알림, 벨 미읽음 배지,
  /project/inbox 페이지, 읽음/모두읽음(project_notifications)

라이브러리 self-host: vis-timeline + Material Symbols 를 static/vendor/ 로
내려받아 CDN 의존 제거.

DB: scripts/sql/project_db_002_attachments_notifications.sql (서버서 적용 필요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:32:00 +09:00
parent ef7f76821d
commit 776f655db4
13 changed files with 1002 additions and 28 deletions
+320 -3
View File
@@ -16,16 +16,36 @@ from __future__ import annotations
import calendar as _calendar
import logging
import uuid
from datetime import date
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi import (
APIRouter,
BackgroundTasks,
Body,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
)
from app.timezone import today_kst
from . import store
# 첨부 업로드 제한
_MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 20MB
logger = logging.getLogger("project.router")
router = APIRouter(prefix="/project", tags=["project"])
@@ -101,6 +121,39 @@ def _notify(
bg.add_task(send_email, to=recipients, subject=subject, body=body)
def _task_recipients(
st: Any, project: dict[str, Any], task: dict[str, Any], *, exclude: str = ""
) -> list[str]:
"""업무 관련 인앱 알림 수신자 = 담당자 + 프로젝트 owner + 멤버 (행위자 제외)."""
emails: set[str] = set()
if task.get("assignee_email"):
emails.add(store.norm_str(task["assignee_email"], lower=True))
if project.get("owner_email"):
emails.add(store.norm_str(project["owner_email"], lower=True))
try:
for m in st.list_members(project_id=project["id"]):
emails.add(store.norm_str(m["user_email"], lower=True))
except Exception: # noqa: BLE001
pass
emails.discard(store.norm_str(exclude, lower=True))
return [e for e in emails if e]
def _inbox(
st: Any, *, recipients: list[str], actor: str, project_id: int | None,
task_id: int | None, ntype: str, title: str, body: str = "",
) -> None:
"""인앱 알림(알림센터) 생성. 실패해도 본 작업을 막지 않는다."""
for r in recipients:
try:
st.add_notification(
recipient_email=r, actor_email=actor, project_id=project_id,
task_id=task_id, type=ntype, title=title, body=body,
)
except Exception: # noqa: BLE001
logger.warning("알림 생성 실패: %s", r)
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
@@ -605,7 +658,7 @@ async def api_create_task(
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# 생성과 동시에 담당자 지정 시 관리자 알림
# 생성과 동시에 담당자 지정 시 관리자 메일 + 담당자 인앱 알림
if task.get("assignee_email"):
_notify(
bg, request,
@@ -623,6 +676,12 @@ async def api_create_task(
f"등록자: {user['email']}\n"
),
)
_inbox(
st, recipients=[task["assignee_email"]], actor=user["email"],
project_id=project_id, task_id=task["id"], ntype="assigned",
title=f"업무 배정: {task['title']}",
body=f"{project['name']} · 마감 {task.get('due_date') or '-'}",
)
return JSONResponse({"task": task}, status_code=201)
@@ -647,6 +706,7 @@ async def api_update_task(
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
project_name = task.get("project_name") or ""
project = st.get_project(project_id=existing["project_id"]) or {"id": existing["project_id"], "name": project_name}
if task.get("_newly_assigned"):
_notify(
bg, request,
@@ -664,6 +724,12 @@ async def api_update_task(
f"변경자: {user['email']}\n"
),
)
_inbox(
st, recipients=[task["_newly_assigned"]], actor=user["email"],
project_id=existing["project_id"], task_id=task_id, ntype="assigned",
title=f"업무 배정: {task['title']}",
body=f"{project_name} · 마감 {task.get('due_date') or '-'}",
)
if task.get("_newly_completed"):
_notify(
bg, request,
@@ -677,6 +743,11 @@ async def api_update_task(
f"완료 처리: {user['email']}\n"
),
)
_inbox(
st, recipients=_task_recipients(st, project, task, exclude=user["email"]),
actor=user["email"], project_id=existing["project_id"], task_id=task_id,
ntype="completed", title=f"업무 완료: {task['title']}", body=project_name,
)
return JSONResponse({"task": task})
@@ -697,3 +768,249 @@ async def api_activity(request: Request, project_id: int) -> JSONResponse:
_require_user(request)
st = _db_or_503(request)
return JSONResponse({"activity": st.list_activity(project_id=project_id, limit=100)})
# ────────────────────────────────────────────────────────────
# JSON API — 댓글
# ────────────────────────────────────────────────────────────
@router.get("/api/tasks/{task_id}/comments")
async def api_list_comments(request: Request, task_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
return JSONResponse({"comments": st.list_comments(task_id=task_id)})
@router.post("/api/tasks/{task_id}/comments")
async def api_add_comment(
request: Request, task_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
try:
comment = st.add_comment(
task_id=task_id,
author_email=user["email"],
author_name=user.get("name") or user["email"].split("@")[0],
body=payload.get("body", ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
project = st.get_project(project_id=task["project_id"]) or {"id": task["project_id"], "name": task.get("project_name", "")}
_inbox(
st, recipients=_task_recipients(st, project, task, exclude=user["email"]),
actor=user["email"], project_id=task["project_id"], task_id=task_id,
ntype="comment", title=f"새 댓글: {task['title']}",
body=comment["body"][:120],
)
return JSONResponse({"comment": comment}, status_code=201)
@router.delete("/api/comments/{comment_id}")
async def api_delete_comment(request: Request, comment_id: int) -> JSONResponse:
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _db_or_503(request)
try:
st.delete_comment(comment_id=comment_id, requester=user["email"], is_admin=is_admin(user))
except KeyError:
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 첨부파일
# ────────────────────────────────────────────────────────────
def _attach_dir(request: Request, task_id: int) -> Path:
base = Path(str(getattr(request.app.state, "data_dir", ".")))
d = base / "project" / str(task_id)
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/tasks/{task_id}/attachments")
async def api_list_attachments(request: Request, task_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
return JSONResponse({"attachments": st.list_attachments(task_id=task_id)})
@router.post("/api/tasks/{task_id}/attachments")
async def api_upload_attachment(
request: Request, task_id: int, file: UploadFile = File(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
data = await file.read()
if len(data) > _MAX_ATTACH_BYTES:
raise HTTPException(status_code=413, detail="파일이 너무 큽니다(최대 20MB).")
if not data:
raise HTTPException(status_code=400, detail="빈 파일입니다.")
orig = (file.filename or "file").replace("\\", "_").replace("/", "_").strip()
suffix = Path(orig).suffix[:16]
stored = f"{uuid.uuid4().hex}{suffix}"
dest = _attach_dir(request, task_id) / stored
dest.write_bytes(data)
att = st.add_attachment(
task_id=task_id, filename=orig, stored_name=stored,
content_type=file.content_type or "application/octet-stream",
size_bytes=len(data), uploaded_by=user["email"],
)
return JSONResponse({"attachment": att}, status_code=201)
@router.get("/api/attachments/{attachment_id}/download")
async def api_download_attachment(request: Request, attachment_id: int):
user = _require_user(request)
st = _db_or_503(request)
att = st.get_attachment(attachment_id=attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.")
task = st.get_task(task_id=att["task_id"])
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
path = _attach_dir(request, att["task_id"]) / att["stored_name"]
if not path.exists():
raise HTTPException(status_code=404, detail="파일이 디스크에 없습니다.")
return FileResponse(
str(path), filename=att["filename"],
media_type=att.get("content_type") or "application/octet-stream",
)
@router.delete("/api/attachments/{attachment_id}")
async def api_delete_attachment(request: Request, attachment_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
att = st.get_attachment(attachment_id=attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.")
task = st.get_task(task_id=att["task_id"])
if task is not None:
_require_manage(request, st, user, task["project_id"])
removed = st.delete_attachment(attachment_id=attachment_id)
try:
(_attach_dir(request, removed["task_id"]) / removed["stored_name"]).unlink(missing_ok=True)
except Exception: # noqa: BLE001
logger.warning("첨부 파일 삭제 실패: %s", removed.get("stored_name"))
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 단계 이름/순서 편집
# ────────────────────────────────────────────────────────────
@router.put("/api/projects/{project_id}/stages/order")
async def api_reorder_stages(
request: Request, project_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
ids = payload.get("ordered_ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.")
st.reorder_stages(project_id=project_id, ordered_ids=[int(i) for i in ids])
return JSONResponse({"stages": st.list_stages(project_id=project_id)})
@router.put("/api/projects/{project_id}/stages/{stage_id}")
async def api_update_stage(
request: Request, project_id: int, stage_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
try:
stage = st.update_stage(
stage_id=stage_id,
name=payload.get("name"),
is_done_stage=payload.get("is_done_stage"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except KeyError:
raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.")
return JSONResponse({"stage": stage})
# ────────────────────────────────────────────────────────────
# 알림센터 (인앱)
# ────────────────────────────────────────────────────────────
@router.get("/api/notifications")
async def api_notifications(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
unread_only = request.query_params.get("unread") in ("1", "true")
items = st.list_notifications(
recipient_email=user["email"], unread_only=unread_only, limit=100
)
return JSONResponse({"notifications": items, "unread": st.count_unread(recipient_email=user["email"])})
@router.get("/api/notifications/unread-count")
async def api_notif_count(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
return JSONResponse({"unread": st.count_unread(recipient_email=user["email"])})
@router.post("/api/notifications/{notification_id}/read")
async def api_notif_read(request: Request, notification_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
st.mark_read(notification_id=notification_id, recipient_email=user["email"])
return JSONResponse({"ok": True})
@router.post("/api/notifications/read-all")
async def api_notif_read_all(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
n = st.mark_all_read(recipient_email=user["email"])
return JSONResponse({"ok": True, "marked": n})
@router.get("/inbox", response_class=HTMLResponse)
async def inbox_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _store(request)
if st is None:
return _render_config_needed(request, user)
items = st.list_notifications(recipient_email=user["email"], limit=100)
return render_template(
request,
"project/inbox.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="project"),
"page_title": "알림센터",
"page_subtitle": "내게 온 프로젝트 알림",
"notifications": items,
},
)