feat(project): 아사나식 프로젝트 관리 모듈 추가
- project_db 신규(projects/members/stages/tasks/comments/activity, project_app CRUD) - 프로젝트/서브프로젝트(self-FK), 업무·진행단계(칸반), 멤버 배정 - 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 토글 + 보드/리스트 - 진입은 로그인 회사 직원 전원, 생성/배정은 관리자 전용 - 업무 배정·완료 시 관리자 메일 알림(app/mail.py, SMTP_* env, 미설정 시 skip) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""프로젝트 관리(아사나식) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다(malaysia/dispatch 패턴).
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/project)
|
||||
- 저장소: `db.py` (project_db / PostgreSQL 전용) + `store.py` (상수/순수 검증)
|
||||
- 템플릿: `templates/project/`
|
||||
- 메일: 전역 `app.mail.send_email` (업무 배정/완료 시 관리자 알림)
|
||||
|
||||
데이터 저장은 project_db 전용이다. PROJECT_DB_URL 미설정 시
|
||||
build_project_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
|
||||
보여준다(앱은 죽지 않음).
|
||||
|
||||
권한: 로그인한 회사(dbxcorp.co.kr) 직원은 모듈 진입 가능. 프로젝트 생성/삭제·
|
||||
사용자 배정은 ERP 관리자(is_admin)만. 배정된 멤버는 서브프로젝트/업무/단계 관리.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import store
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router", "store", "build_project_store"]
|
||||
|
||||
|
||||
def build_project_store(*, dsn: str | None) -> Any:
|
||||
"""PROJECT_DB_URL 이 있으면 ProjectStore, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import ProjectStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return ProjectStore(dsn)
|
||||
@@ -0,0 +1,519 @@
|
||||
"""project_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `PROJECT_DB_URL`
|
||||
(예: postgresql://project_app:<pwd>@postgres-db:5432/project_db)
|
||||
- 스키마는 앱이 만들지 않는다. `scripts/sql/project_db_init.sql` 을 superuser 가
|
||||
사전 적용한다. 앱 계정(project_app)은 CRUD 권한만 받는다.
|
||||
- 연결 풀은 lazy open.
|
||||
|
||||
순수 검증/상수는 store.py 에 있고, 여기서는 DB I/O 와 직렬화만 담당한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST, now_kst
|
||||
|
||||
from . import store
|
||||
|
||||
logger = logging.getLogger("project.db")
|
||||
|
||||
|
||||
class ProjectStore:
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 프로젝트 / 서브프로젝트
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_projects(
|
||||
self, *, include_archived: bool = False, member_email: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""전체 프로젝트(서브 포함) 평면 목록. member_email 지정 시 그 사용자가
|
||||
멤버이거나 owner 인 프로젝트만(관리자는 라우터에서 None 으로 전체 조회).
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if not include_archived:
|
||||
clauses.append("p.status <> 'archived'")
|
||||
if member_email:
|
||||
clauses.append(
|
||||
"(p.owner_email = %s OR EXISTS ("
|
||||
" SELECT 1 FROM project_members m "
|
||||
" WHERE m.project_id = p.id AND m.user_email = %s))"
|
||||
)
|
||||
params.extend([member_email, member_email])
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT p.* FROM projects p {where} "
|
||||
"ORDER BY p.parent_id NULLS FIRST, p.sort_order ASC, p.id ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_project(self, *, project_id: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM projects WHERE id = %s", (project_id,)
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
def create_project(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
parent_id: int | None = None,
|
||||
description: str = "",
|
||||
color: str = "",
|
||||
owner_email: str = "",
|
||||
start_date: str | None = None,
|
||||
due_date: str | None = None,
|
||||
created_by: str = "",
|
||||
seed_stages: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
nm = store.validate_project_name(name)
|
||||
col = store.validate_color(color)
|
||||
sd = store.validate_date(start_date)
|
||||
dd = store.validate_date(due_date)
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
if parent_id is not None:
|
||||
parent = conn.execute(
|
||||
"SELECT id FROM projects WHERE id = %s", (parent_id,)
|
||||
).fetchone()
|
||||
if not parent:
|
||||
raise KeyError(f"상위 프로젝트를 찾을 수 없습니다: {parent_id}")
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO projects
|
||||
(parent_id, name, description, color, owner_email,
|
||||
start_date, due_date, created_by)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
parent_id,
|
||||
nm,
|
||||
store.norm_str(description),
|
||||
col,
|
||||
store.norm_str(owner_email, lower=True),
|
||||
sd,
|
||||
dd,
|
||||
store.norm_str(created_by, lower=True),
|
||||
),
|
||||
).fetchone()
|
||||
pid = row["id"]
|
||||
if seed_stages:
|
||||
for i, (stage_name, is_done) in enumerate(store.DEFAULT_STAGES):
|
||||
conn.execute(
|
||||
"INSERT INTO project_stages "
|
||||
"(project_id, name, sort_order, is_done_stage) "
|
||||
"VALUES (%s,%s,%s,%s)",
|
||||
(pid, stage_name, i, is_done),
|
||||
)
|
||||
self._log(conn, project_id=pid, actor=created_by,
|
||||
action=store.ACTION_CREATED, detail=nm)
|
||||
return self._serialize(row)
|
||||
|
||||
def update_project(self, *, project_id: int, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"name": lambda v: store.validate_project_name(v),
|
||||
"description": lambda v: store.norm_str(v),
|
||||
"color": lambda v: store.validate_color(v),
|
||||
"owner_email": lambda v: store.norm_str(v, lower=True),
|
||||
"start_date": lambda v: store.validate_date(v),
|
||||
"due_date": lambda v: store.validate_date(v),
|
||||
"status": lambda v: (store.norm_str(v, lower=True)
|
||||
if store.norm_str(v, lower=True) in store.PROJECT_STATUSES
|
||||
else "active"),
|
||||
}
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
for key, fn in allowed.items():
|
||||
if key in fields:
|
||||
sets.append(f"{key} = %s")
|
||||
params.append(fn(fields[key]))
|
||||
if not sets:
|
||||
existing = self.get_project(project_id=project_id)
|
||||
if existing is None:
|
||||
raise KeyError(project_id)
|
||||
return existing
|
||||
params.append(project_id)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
f"UPDATE projects SET {', '.join(sets)} WHERE id = %s RETURNING *",
|
||||
params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(project_id)
|
||||
return self._serialize(row)
|
||||
|
||||
def delete_project(self, *, project_id: int) -> None:
|
||||
"""프로젝트(서브프로젝트·단계·업무 CASCADE) 삭제."""
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute("DELETE FROM projects WHERE id = %s", (project_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(project_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 멤버
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_members(self, *, project_id: int) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_members WHERE project_id = %s "
|
||||
"ORDER BY role DESC, user_email ASC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def is_member(self, *, project_id: int, user_email: str) -> bool:
|
||||
email = store.norm_str(user_email, lower=True)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM projects p "
|
||||
"LEFT JOIN project_members m "
|
||||
" ON m.project_id = p.id AND m.user_email = %s "
|
||||
"WHERE p.id = %s AND (p.owner_email = %s OR m.id IS NOT NULL) LIMIT 1",
|
||||
(email, project_id, email),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
def add_member(
|
||||
self, *, project_id: int, user_email: str, user_name: str = "", role: str = "member"
|
||||
) -> dict[str, Any]:
|
||||
email = store.norm_str(user_email, lower=True)
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일이 아닙니다.")
|
||||
rl = store.validate_role(role)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO project_members (project_id, user_email, user_name, role)
|
||||
VALUES (%s,%s,%s,%s)
|
||||
ON CONFLICT (project_id, user_email) DO UPDATE
|
||||
SET role = EXCLUDED.role, user_name = EXCLUDED.user_name
|
||||
RETURNING *
|
||||
""",
|
||||
(project_id, email, store.norm_str(user_name), rl),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
def remove_member(self, *, project_id: int, user_email: str) -> None:
|
||||
email = store.norm_str(user_email, lower=True)
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_members WHERE project_id = %s AND user_email = %s",
|
||||
(project_id, email),
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 단계 (stages)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_stages(self, *, project_id: int) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_stages WHERE project_id = %s "
|
||||
"ORDER BY sort_order ASC, id ASC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def add_stage(
|
||||
self, *, project_id: int, name: str, is_done_stage: bool = False
|
||||
) -> dict[str, Any]:
|
||||
nm = store.norm_str(name)
|
||||
if not nm:
|
||||
raise ValueError("단계 이름은 필수입니다.")
|
||||
with self._pool.connection() as conn:
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n "
|
||||
"FROM project_stages WHERE project_id = %s",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
row = conn.execute(
|
||||
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage) "
|
||||
"VALUES (%s,%s,%s,%s) RETURNING *",
|
||||
(project_id, nm, int(nxt["n"]), bool(is_done_stage)),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
def delete_stage(self, *, stage_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute("DELETE FROM project_stages WHERE id = %s", (stage_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(stage_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 업무 (tasks)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_tasks(
|
||||
self,
|
||||
*,
|
||||
project_id: int | None = None,
|
||||
assignee_email: str | None = None,
|
||||
include_subprojects: bool = False,
|
||||
limit: int = 1000,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if project_id is not None:
|
||||
if include_subprojects:
|
||||
clauses.append(
|
||||
"t.project_id IN ("
|
||||
" WITH RECURSIVE tree AS ("
|
||||
" SELECT id FROM projects WHERE id = %s "
|
||||
" UNION ALL "
|
||||
" SELECT p.id FROM projects p JOIN tree ON p.parent_id = tree.id"
|
||||
" ) SELECT id FROM tree)"
|
||||
)
|
||||
params.append(project_id)
|
||||
else:
|
||||
clauses.append("t.project_id = %s")
|
||||
params.append(project_id)
|
||||
if assignee_email:
|
||||
clauses.append("t.assignee_email = %s")
|
||||
params.append(store.norm_str(assignee_email, lower=True))
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
params.append(int(limit))
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT t.*, p.name AS project_name, p.color AS project_color, "
|
||||
f" s.name AS stage_name, s.is_done_stage "
|
||||
f"FROM tasks t "
|
||||
f"JOIN projects p ON p.id = t.project_id "
|
||||
f"LEFT JOIN project_stages s ON s.id = t.stage_id "
|
||||
f"{where} "
|
||||
"ORDER BY t.sort_order ASC, t.id ASC LIMIT %s",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_task(self, *, task_id: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT t.*, p.name AS project_name, p.color AS project_color, "
|
||||
" s.name AS stage_name, s.is_done_stage "
|
||||
"FROM tasks t JOIN projects p ON p.id = t.project_id "
|
||||
"LEFT JOIN project_stages s ON s.id = t.stage_id "
|
||||
"WHERE t.id = %s",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
title: str,
|
||||
stage_id: int | None = None,
|
||||
description: str = "",
|
||||
assignee_email: str = "",
|
||||
assignee_name: str = "",
|
||||
priority: str = "normal",
|
||||
start_date: str | None = None,
|
||||
due_date: str | None = None,
|
||||
created_by: str = "",
|
||||
) -> dict[str, Any]:
|
||||
ttl = store.validate_task_title(title)
|
||||
pr = store.validate_priority(priority)
|
||||
sd = store.validate_date(start_date)
|
||||
dd = store.validate_date(due_date)
|
||||
assignee = store.norm_str(assignee_email, lower=True)
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
# stage 미지정 시 프로젝트의 첫 단계로
|
||||
if stage_id is None:
|
||||
st = conn.execute(
|
||||
"SELECT id FROM project_stages WHERE project_id = %s "
|
||||
"ORDER BY sort_order ASC, id ASC LIMIT 1",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
stage_id = st["id"] if st else None
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM tasks "
|
||||
"WHERE project_id = %s",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO tasks
|
||||
(project_id, stage_id, title, description, assignee_email,
|
||||
assignee_name, priority, start_date, due_date, sort_order, created_by)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
project_id, stage_id, ttl, store.norm_str(description),
|
||||
assignee, store.norm_str(assignee_name), pr, sd, dd,
|
||||
int(nxt["n"]), store.norm_str(created_by, lower=True),
|
||||
),
|
||||
).fetchone()
|
||||
if assignee:
|
||||
self._log(conn, project_id=project_id, task_id=row["id"],
|
||||
actor=created_by, action=store.ACTION_ASSIGNED,
|
||||
detail=f"{ttl} → {assignee}")
|
||||
return self.get_task(task_id=row["id"])
|
||||
|
||||
def update_task(
|
||||
self, *, task_id: int, fields: dict[str, Any], actor: str = ""
|
||||
) -> dict[str, Any]:
|
||||
"""업무 수정. 반환에 알림 힌트를 담는다(라우터가 메일 발송 판단).
|
||||
|
||||
반환 dict 에 추가되는 키:
|
||||
_newly_assigned: 새 담당자 이메일 or None
|
||||
_newly_completed: 완료로 전환됐으면 True
|
||||
"""
|
||||
prev = self.get_task(task_id=task_id)
|
||||
if prev is None:
|
||||
raise KeyError(task_id)
|
||||
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
newly_assigned: str | None = None
|
||||
newly_completed = False
|
||||
|
||||
if "title" in fields:
|
||||
sets.append("title = %s")
|
||||
params.append(store.validate_task_title(fields["title"]))
|
||||
if "description" in fields:
|
||||
sets.append("description = %s")
|
||||
params.append(store.norm_str(fields["description"]))
|
||||
if "priority" in fields:
|
||||
sets.append("priority = %s")
|
||||
params.append(store.validate_priority(fields["priority"]))
|
||||
if "start_date" in fields:
|
||||
sets.append("start_date = %s")
|
||||
params.append(store.validate_date(fields["start_date"]))
|
||||
if "due_date" in fields:
|
||||
sets.append("due_date = %s")
|
||||
params.append(store.validate_date(fields["due_date"]))
|
||||
if "assignee_email" in fields:
|
||||
new_assignee = store.norm_str(fields["assignee_email"], lower=True)
|
||||
sets.append("assignee_email = %s")
|
||||
params.append(new_assignee)
|
||||
if "assignee_name" in fields:
|
||||
sets.append("assignee_name = %s")
|
||||
params.append(store.norm_str(fields["assignee_name"]))
|
||||
if new_assignee and new_assignee != (prev.get("assignee_email") or ""):
|
||||
newly_assigned = new_assignee
|
||||
elif "assignee_name" in fields:
|
||||
sets.append("assignee_name = %s")
|
||||
params.append(store.norm_str(fields["assignee_name"]))
|
||||
|
||||
# 단계 변경 → 완료단계 진입 시 completed_at 자동 세팅
|
||||
if "stage_id" in fields:
|
||||
new_stage = fields["stage_id"]
|
||||
sets.append("stage_id = %s")
|
||||
params.append(new_stage)
|
||||
done = self._stage_is_done(new_stage)
|
||||
if done and not prev.get("completed_at"):
|
||||
sets.append("completed_at = %s")
|
||||
params.append(now_kst())
|
||||
newly_completed = True
|
||||
elif not done and prev.get("completed_at"):
|
||||
sets.append("completed_at = NULL")
|
||||
|
||||
if not sets:
|
||||
return {**prev, "_newly_assigned": None, "_newly_completed": False}
|
||||
|
||||
params.append(task_id)
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
f"UPDATE tasks SET {', '.join(sets)} WHERE id = %s", params
|
||||
)
|
||||
if newly_assigned:
|
||||
self._log(conn, project_id=prev["project_id"], task_id=task_id,
|
||||
actor=actor, action=store.ACTION_ASSIGNED,
|
||||
detail=f"{prev['title']} → {newly_assigned}")
|
||||
if newly_completed:
|
||||
self._log(conn, project_id=prev["project_id"], task_id=task_id,
|
||||
actor=actor, action=store.ACTION_COMPLETED,
|
||||
detail=prev["title"])
|
||||
out = self.get_task(task_id=task_id)
|
||||
out["_newly_assigned"] = newly_assigned
|
||||
out["_newly_completed"] = newly_completed
|
||||
return out
|
||||
|
||||
def delete_task(self, *, task_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute("DELETE FROM tasks WHERE id = %s", (task_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(task_id)
|
||||
|
||||
def _stage_is_done(self, stage_id: Any) -> bool:
|
||||
if stage_id is None:
|
||||
return False
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT is_done_stage FROM project_stages WHERE id = %s", (stage_id,)
|
||||
).fetchone()
|
||||
return bool(row and row["is_done_stage"])
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 활동 이력
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_activity(self, *, project_id: int, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_activity WHERE project_id = %s "
|
||||
"ORDER BY created_at DESC, id DESC LIMIT %s",
|
||||
(project_id, int(limit)),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def _log(
|
||||
conn: Any, *, project_id: int | None = None, task_id: int | None = None,
|
||||
actor: str = "", action: str = "", detail: str = "",
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO project_activity "
|
||||
"(project_id, task_id, actor_email, action, detail) "
|
||||
"VALUES (%s,%s,%s,%s,%s)",
|
||||
(project_id, task_id, store.norm_str(actor, lower=True), action,
|
||||
store.norm_str(detail)),
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 직렬화
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@staticmethod
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
elif isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"):
|
||||
if k in out and out[k] is not None:
|
||||
try:
|
||||
out[k] = int(out[k])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for k in ("is_done_stage",):
|
||||
if k in out and out[k] is not None:
|
||||
out[k] = bool(out[k])
|
||||
return out
|
||||
@@ -0,0 +1,562 @@
|
||||
"""프로젝트 관리(아사나식) 모듈 라우터.
|
||||
|
||||
- 경로: /project
|
||||
- 권한:
|
||||
· 모듈 진입: 로그인한 회사 직원 전원(도메인 게이트는 OAuth 단에서 처리).
|
||||
· 프로젝트 생성/삭제·사용자 배정: ERP 관리자(is_admin) 전용.
|
||||
· 서브프로젝트/업무/단계 관리: 해당 프로젝트 멤버(또는 owner) 또는 관리자.
|
||||
- 데이터: ProjectStore (project_db / PostgreSQL) 전용.
|
||||
PROJECT_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
|
||||
- 메일: 업무가 직원에게 '배정'되거나 '완료'되면 관리자에게 알림(BackgroundTasks).
|
||||
|
||||
뷰: 메인은 달력(FullCalendar)/타임라인(vis-timeline) 토글. 추가로 보드(칸반)·리스트.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.timezone import today_kst
|
||||
|
||||
from . import store
|
||||
|
||||
logger = logging.getLogger("project.router")
|
||||
|
||||
router = APIRouter(prefix="/project", tags=["project"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 공용 헬퍼
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _store(request: Request) -> Any:
|
||||
return getattr(request.app.state, "project_store", None)
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict[str, Any]:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
user = _require_user(request)
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 할 수 있는 작업입니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _can_manage(request: Request, st: Any, user: dict[str, Any], project_id: int) -> bool:
|
||||
"""프로젝트 단위 관리 권한: 관리자이거나 멤버/owner."""
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if is_admin(user):
|
||||
return True
|
||||
return bool(st.is_member(project_id=project_id, user_email=user["email"]))
|
||||
|
||||
|
||||
def _require_manage(request: Request, st: Any, user: dict[str, Any], project_id: int) -> None:
|
||||
if not _can_manage(request, st, user, project_id):
|
||||
raise HTTPException(status_code=403, detail="이 프로젝트에 대한 권한이 없습니다.")
|
||||
|
||||
|
||||
def _admin_emails(request: Request) -> list[str]:
|
||||
"""알림 수신 관리자 이메일 목록.
|
||||
|
||||
PROJECT_NOTIFY_EMAIL(쉼표구분)이 있으면 우선, 없으면 user_store 의 admin 전원.
|
||||
"""
|
||||
import os
|
||||
|
||||
override = (os.getenv("PROJECT_NOTIFY_EMAIL", "") or "").strip()
|
||||
if override:
|
||||
return [e.strip() for e in override.split(",") if e.strip()]
|
||||
from app.main import user_store # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return [u["email"] for u in user_store.list_all() if is_admin(u)]
|
||||
|
||||
|
||||
def _notify(
|
||||
bg: BackgroundTasks, request: Request, *, subject: str, body: str
|
||||
) -> None:
|
||||
"""관리자에게 백그라운드 메일 발송 예약. 수신자 없거나 SMTP 미설정 시 조용히 skip."""
|
||||
from app.mail import send_email # noqa: WPS433
|
||||
|
||||
recipients = _admin_emails(request)
|
||||
if not recipients:
|
||||
return
|
||||
bg.add_task(send_email, to=recipients, subject=subject, body=body)
|
||||
|
||||
|
||||
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
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": "프로젝트 관리 모듈이 아직 설정되지 않았습니다. "
|
||||
"PROJECT_DB_URL 환경변수를 설정하고 "
|
||||
"scripts/sql/project_db_init.sql 로 project_db 를 초기화한 뒤 "
|
||||
"컨테이너를 재기동하세요.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="project"),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""평면 프로젝트 목록 → parent_id 기준 트리(children 키)."""
|
||||
by_id: dict[int, dict[str, Any]] = {}
|
||||
roots: list[dict[str, Any]] = []
|
||||
for p in projects:
|
||||
p = {**p, "children": []}
|
||||
by_id[p["id"]] = p
|
||||
for p in by_id.values():
|
||||
parent_id = p.get("parent_id")
|
||||
if parent_id and parent_id in by_id:
|
||||
by_id[parent_id]["children"].append(p)
|
||||
else:
|
||||
roots.append(p)
|
||||
return roots
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 페이지
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.get("/health")
|
||||
async def health(request: Request) -> JSONResponse:
|
||||
st = _store(request)
|
||||
if st is None:
|
||||
return JSONResponse({"status": "not_configured"}, status_code=503)
|
||||
try:
|
||||
st.list_projects(include_archived=False)
|
||||
return JSONResponse({"status": "ok"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return JSONResponse({"status": "error", "detail": str(exc)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(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)
|
||||
|
||||
admin = is_admin(user)
|
||||
projects = st.list_projects(
|
||||
include_archived=False,
|
||||
member_email=None if admin else user["email"],
|
||||
)
|
||||
tree = _build_tree(projects)
|
||||
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
|
||||
return render_template(
|
||||
request,
|
||||
"project/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": admin,
|
||||
"nav_items": build_erp_nav(user, active="project"),
|
||||
"page_title": "프로젝트 관리",
|
||||
"page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.",
|
||||
"tree": tree,
|
||||
"my_tasks": my_tasks,
|
||||
"today": today_kst().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/p/{project_id}", response_class=HTMLResponse)
|
||||
async def project_page(request: Request, project_id: int) -> 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)
|
||||
|
||||
project = st.get_project(project_id=project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
|
||||
admin = is_admin(user)
|
||||
all_projects = st.list_projects(
|
||||
include_archived=False,
|
||||
member_email=None if admin else user["email"],
|
||||
)
|
||||
tree = _build_tree(all_projects)
|
||||
stages = st.list_stages(project_id=project_id)
|
||||
tasks = st.list_tasks(project_id=project_id, include_subprojects=True, limit=2000)
|
||||
members = st.list_members(project_id=project_id)
|
||||
can_manage = _can_manage(request, st, user, project_id)
|
||||
subprojects = [p for p in all_projects if p.get("parent_id") == project_id]
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"project/project.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": admin,
|
||||
"can_manage": can_manage,
|
||||
"nav_items": build_erp_nav(user, active="project"),
|
||||
"page_title": project["name"],
|
||||
"page_subtitle": project.get("description") or "프로젝트 보드",
|
||||
"project": project,
|
||||
"tree": tree,
|
||||
"stages": stages,
|
||||
"tasks": tasks,
|
||||
"members": members,
|
||||
"subprojects": subprojects,
|
||||
"priority_labels": store.PRIORITY_LABELS,
|
||||
"color_palette": list(store.COLOR_PALETTE),
|
||||
"today": today_kst().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# JSON API — 프로젝트
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _db_or_503(request: Request) -> Any:
|
||||
st = _store(request)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=503, detail="프로젝트 모듈이 설정되지 않았습니다.")
|
||||
return st
|
||||
|
||||
|
||||
@router.get("/api/projects")
|
||||
async def api_list_projects(request: Request) -> JSONResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
admin = is_admin(user)
|
||||
projects = st.list_projects(
|
||||
include_archived=False,
|
||||
member_email=None if admin else user["email"],
|
||||
)
|
||||
return JSONResponse({"projects": projects, "tree": _build_tree(projects)})
|
||||
|
||||
|
||||
@router.post("/api/projects")
|
||||
async def api_create_project(
|
||||
request: Request, payload: dict[str, Any] = Body(...)
|
||||
) -> JSONResponse:
|
||||
"""최상위 프로젝트 생성 — 관리자 전용."""
|
||||
user = _require_admin(request)
|
||||
st = _db_or_503(request)
|
||||
try:
|
||||
project = st.create_project(
|
||||
name=payload.get("name", ""),
|
||||
parent_id=None,
|
||||
description=payload.get("description", ""),
|
||||
color=payload.get("color", ""),
|
||||
owner_email=payload.get("owner_email", "") or user["email"],
|
||||
start_date=payload.get("start_date"),
|
||||
due_date=payload.get("due_date"),
|
||||
created_by=user["email"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"project": project}, status_code=201)
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/subprojects")
|
||||
async def api_create_subproject(
|
||||
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)
|
||||
try:
|
||||
project = st.create_project(
|
||||
name=payload.get("name", ""),
|
||||
parent_id=project_id,
|
||||
description=payload.get("description", ""),
|
||||
color=payload.get("color", ""),
|
||||
owner_email=payload.get("owner_email", "") or user["email"],
|
||||
start_date=payload.get("start_date"),
|
||||
due_date=payload.get("due_date"),
|
||||
created_by=user["email"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return JSONResponse({"project": project}, status_code=201)
|
||||
|
||||
|
||||
@router.put("/api/projects/{project_id}")
|
||||
async def api_update_project(
|
||||
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)
|
||||
try:
|
||||
project = st.update_project(project_id=project_id, fields=payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
return JSONResponse({"project": project})
|
||||
|
||||
|
||||
@router.delete("/api/projects/{project_id}")
|
||||
async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
|
||||
"""프로젝트 삭제 — 서브프로젝트는 멤버도, 최상위는 관리자만.
|
||||
|
||||
아사나 요구사항: 배정된 사용자는 '서브프로젝트' 추가/삭제 가능.
|
||||
최상위 프로젝트 삭제는 관리자 전용.
|
||||
"""
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
project = st.get_project(project_id=project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
is_sub = project.get("parent_id") is not None
|
||||
if is_sub:
|
||||
_require_manage(request, st, user, project_id)
|
||||
elif not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="최상위 프로젝트 삭제는 관리자만 가능합니다.")
|
||||
st.delete_project(project_id=project_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# JSON API — 멤버 (관리자가 사용자 배정)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.get("/api/projects/{project_id}/members")
|
||||
async def api_list_members(request: Request, project_id: int) -> JSONResponse:
|
||||
_require_user(request)
|
||||
st = _db_or_503(request)
|
||||
return JSONResponse({"members": st.list_members(project_id=project_id)})
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/members")
|
||||
async def api_add_member(
|
||||
request: Request, project_id: int, payload: dict[str, Any] = Body(...)
|
||||
) -> JSONResponse:
|
||||
"""사용자를 프로젝트에 배정 — 관리자 전용."""
|
||||
_require_admin(request)
|
||||
st = _db_or_503(request)
|
||||
try:
|
||||
member = st.add_member(
|
||||
project_id=project_id,
|
||||
user_email=payload.get("user_email", ""),
|
||||
user_name=payload.get("user_name", ""),
|
||||
role=payload.get("role", "member"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"member": member}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/projects/{project_id}/members/{user_email}")
|
||||
async def api_remove_member(
|
||||
request: Request, project_id: int, user_email: str
|
||||
) -> JSONResponse:
|
||||
_require_admin(request)
|
||||
st = _db_or_503(request)
|
||||
st.remove_member(project_id=project_id, user_email=user_email)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# JSON API — 단계 (stages)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.get("/api/projects/{project_id}/stages")
|
||||
async def api_list_stages(request: Request, project_id: int) -> JSONResponse:
|
||||
_require_user(request)
|
||||
st = _db_or_503(request)
|
||||
return JSONResponse({"stages": st.list_stages(project_id=project_id)})
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/stages")
|
||||
async def api_add_stage(
|
||||
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)
|
||||
try:
|
||||
stage = st.add_stage(
|
||||
project_id=project_id,
|
||||
name=payload.get("name", ""),
|
||||
is_done_stage=bool(payload.get("is_done_stage", False)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"stage": stage}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/projects/{project_id}/stages/{stage_id}")
|
||||
async def api_delete_stage(
|
||||
request: Request, project_id: int, stage_id: int
|
||||
) -> JSONResponse:
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
_require_manage(request, st, user, project_id)
|
||||
try:
|
||||
st.delete_stage(stage_id=stage_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.")
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# JSON API — 업무 (tasks)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.get("/api/projects/{project_id}/tasks")
|
||||
async def api_list_tasks(request: Request, project_id: int) -> JSONResponse:
|
||||
_require_user(request)
|
||||
st = _db_or_503(request)
|
||||
tasks = st.list_tasks(project_id=project_id, include_subprojects=True, limit=2000)
|
||||
return JSONResponse({"tasks": tasks})
|
||||
|
||||
|
||||
@router.get("/api/my-tasks")
|
||||
async def api_my_tasks(request: Request) -> JSONResponse:
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
return JSONResponse({"tasks": st.list_tasks(assignee_email=user["email"], limit=1000)})
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/tasks")
|
||||
async def api_create_task(
|
||||
request: Request,
|
||||
project_id: int,
|
||||
bg: BackgroundTasks,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
) -> JSONResponse:
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
_require_manage(request, st, user, project_id)
|
||||
project = st.get_project(project_id=project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
try:
|
||||
task = st.create_task(
|
||||
project_id=project_id,
|
||||
title=payload.get("title", ""),
|
||||
stage_id=payload.get("stage_id"),
|
||||
description=payload.get("description", ""),
|
||||
assignee_email=payload.get("assignee_email", ""),
|
||||
assignee_name=payload.get("assignee_name", ""),
|
||||
priority=payload.get("priority", "normal"),
|
||||
start_date=payload.get("start_date"),
|
||||
due_date=payload.get("due_date"),
|
||||
created_by=user["email"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
# 생성과 동시에 담당자 지정 시 관리자 알림
|
||||
if task.get("assignee_email"):
|
||||
_notify(
|
||||
bg, request,
|
||||
subject=store.build_subject_assigned(
|
||||
project_name=project["name"],
|
||||
task_title=task["title"],
|
||||
assignee=task["assignee_email"],
|
||||
),
|
||||
body=(
|
||||
f"프로젝트: {project['name']}\n"
|
||||
f"업무: {task['title']}\n"
|
||||
f"담당자: {task.get('assignee_name') or task['assignee_email']} "
|
||||
f"({task['assignee_email']})\n"
|
||||
f"마감일: {task.get('due_date') or '-'}\n"
|
||||
f"등록자: {user['email']}\n"
|
||||
),
|
||||
)
|
||||
return JSONResponse({"task": task}, status_code=201)
|
||||
|
||||
|
||||
@router.put("/api/tasks/{task_id}")
|
||||
async def api_update_task(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
bg: BackgroundTasks,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
) -> JSONResponse:
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
existing = st.get_task(task_id=task_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
_require_manage(request, st, user, existing["project_id"])
|
||||
try:
|
||||
task = st.update_task(task_id=task_id, fields=payload, actor=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
|
||||
project_name = task.get("project_name") or ""
|
||||
if task.get("_newly_assigned"):
|
||||
_notify(
|
||||
bg, request,
|
||||
subject=store.build_subject_assigned(
|
||||
project_name=project_name,
|
||||
task_title=task["title"],
|
||||
assignee=task["_newly_assigned"],
|
||||
),
|
||||
body=(
|
||||
f"프로젝트: {project_name}\n"
|
||||
f"업무: {task['title']}\n"
|
||||
f"담당자: {task.get('assignee_name') or task['_newly_assigned']} "
|
||||
f"({task['_newly_assigned']})\n"
|
||||
f"마감일: {task.get('due_date') or '-'}\n"
|
||||
f"변경자: {user['email']}\n"
|
||||
),
|
||||
)
|
||||
if task.get("_newly_completed"):
|
||||
_notify(
|
||||
bg, request,
|
||||
subject=store.build_subject_completed(
|
||||
project_name=project_name, task_title=task["title"]
|
||||
),
|
||||
body=(
|
||||
f"프로젝트: {project_name}\n"
|
||||
f"완료된 업무: {task['title']}\n"
|
||||
f"담당자: {task.get('assignee_name') or task.get('assignee_email') or '-'}\n"
|
||||
f"완료 처리: {user['email']}\n"
|
||||
),
|
||||
)
|
||||
return JSONResponse({"task": task})
|
||||
|
||||
|
||||
@router.delete("/api/tasks/{task_id}")
|
||||
async def api_delete_task(request: Request, task_id: int) -> JSONResponse:
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
existing = st.get_task(task_id=task_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
_require_manage(request, st, user, existing["project_id"])
|
||||
st.delete_task(task_id=task_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/activity")
|
||||
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)})
|
||||
@@ -0,0 +1,120 @@
|
||||
"""프로젝트 관리 모듈 — 상수 / 기본값 / 순수 검증 로직.
|
||||
|
||||
DB I/O 는 db.py(ProjectStore)가 담당하고, 여기서는 상수와 입력 검증 등
|
||||
DB 없이 단위 테스트 가능한 순수 함수만 둔다(malaysia.store 패턴).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 프로젝트 생성 시 자동으로 깔리는 기본 진행 단계(칸반 컬럼).
|
||||
# (name, is_done_stage) — 마지막 '완료' 단계로 옮기면 업무 완료 처리.
|
||||
DEFAULT_STAGES: tuple[tuple[str, bool], ...] = (
|
||||
("할 일", False),
|
||||
("진행 중", False),
|
||||
("검토", False),
|
||||
("완료", True),
|
||||
)
|
||||
|
||||
# 업무 우선순위
|
||||
PRIORITIES: tuple[str, ...] = ("low", "normal", "high")
|
||||
PRIORITY_LABELS: dict[str, str] = {"low": "낮음", "normal": "보통", "high": "높음"}
|
||||
|
||||
# 프로젝트 상태
|
||||
PROJECT_STATUSES: tuple[str, ...] = ("active", "archived")
|
||||
|
||||
# 멤버 역할
|
||||
MEMBER_ROLES: tuple[str, ...] = ("manager", "member")
|
||||
|
||||
# 활동(activity) 종류 — 메일 트리거/타임라인 표시용
|
||||
ACTION_CREATED = "created"
|
||||
ACTION_ASSIGNED = "assigned"
|
||||
ACTION_COMPLETED = "completed"
|
||||
ACTION_STAGE_CHANGED = "stage_changed"
|
||||
ACTION_REOPENED = "reopened"
|
||||
|
||||
# 프로젝트 색상 팔레트(아사나 느낌) — UI 선택지
|
||||
COLOR_PALETTE: tuple[str, ...] = (
|
||||
"#4573d2", # blue
|
||||
"#37a3a3", # teal
|
||||
"#62a420", # green
|
||||
"#e8a33d", # amber
|
||||
"#e8384f", # red
|
||||
"#aa62e3", # purple
|
||||
"#f06a6a", # coral
|
||||
"#5a6772", # slate
|
||||
)
|
||||
|
||||
|
||||
def norm_str(s: Any, *, lower: bool = False) -> str:
|
||||
out = str(s or "").strip()
|
||||
return out.lower() if lower else out
|
||||
|
||||
|
||||
def validate_priority(p: Any) -> str:
|
||||
v = norm_str(p, lower=True)
|
||||
return v if v in PRIORITIES else "normal"
|
||||
|
||||
|
||||
def validate_role(r: Any) -> str:
|
||||
v = norm_str(r, lower=True)
|
||||
return v if v in MEMBER_ROLES else "member"
|
||||
|
||||
|
||||
def validate_project_name(name: Any) -> str:
|
||||
v = norm_str(name)
|
||||
if not v:
|
||||
raise ValueError("프로젝트 이름은 필수입니다.")
|
||||
if len(v) > 200:
|
||||
raise ValueError("프로젝트 이름이 너무 깁니다(200자 이내).")
|
||||
return v
|
||||
|
||||
|
||||
def validate_task_title(title: Any) -> str:
|
||||
v = norm_str(title)
|
||||
if not v:
|
||||
raise ValueError("업무 제목은 필수입니다.")
|
||||
if len(v) > 300:
|
||||
raise ValueError("업무 제목이 너무 깁니다(300자 이내).")
|
||||
return v
|
||||
|
||||
|
||||
def validate_color(color: Any) -> str:
|
||||
v = norm_str(color)
|
||||
if not v:
|
||||
return COLOR_PALETTE[0]
|
||||
# 매우 단순한 hex 검증 — #RGB / #RRGGBB
|
||||
if v.startswith("#") and len(v) in (4, 7):
|
||||
try:
|
||||
int(v[1:], 16)
|
||||
return v.lower()
|
||||
except ValueError:
|
||||
pass
|
||||
return COLOR_PALETTE[0]
|
||||
|
||||
|
||||
def validate_date(d: Any) -> str | None:
|
||||
"""'YYYY-MM-DD' 형식만 통과. 빈값은 None."""
|
||||
from datetime import date as _date # noqa: WPS433
|
||||
|
||||
v = norm_str(d)
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
_date.fromisoformat(v[:10])
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(f"날짜 형식이 올바르지 않습니다: {d}")
|
||||
return v[:10]
|
||||
|
||||
|
||||
def build_subject_assigned(*, project_name: str, task_title: str, assignee: str) -> str:
|
||||
return f"[DBX 프로젝트] 업무 배정: {project_name} · {task_title} → {assignee}"
|
||||
|
||||
|
||||
def build_subject_completed(*, project_name: str, task_title: str) -> str:
|
||||
return f"[DBX 프로젝트] 업무 완료: {project_name} · {task_title}"
|
||||
|
||||
|
||||
def build_subject_project_done(*, project_name: str) -> str:
|
||||
return f"[DBX 프로젝트] 프로젝트 완료: {project_name}"
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pj-wrap" data-is-admin="{{ 'true' if is_admin else 'false' }}">
|
||||
|
||||
<div class="pj-home">
|
||||
<!-- 프로젝트 목록 -->
|
||||
<section class="pj-home-main">
|
||||
<div class="pj-section-head">
|
||||
<h2>프로젝트</h2>
|
||||
{% if is_admin %}
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-project">
|
||||
+ 새 프로젝트
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not tree %}
|
||||
<div class="pj-empty">
|
||||
아직 프로젝트가 없습니다.
|
||||
{% if is_admin %}상단의 <b>새 프로젝트</b> 버튼으로 만들어 보세요.
|
||||
{% else %}관리자가 프로젝트에 배정하면 여기에 표시됩니다.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="pj-project-grid">
|
||||
{% for p in tree %}
|
||||
<a class="pj-project-card" href="/project/p/{{ p.id }}"
|
||||
style="--pj-color: {{ p.color }}">
|
||||
<span class="pj-project-dot"></span>
|
||||
<div class="pj-project-body">
|
||||
<div class="pj-project-name">{{ p.name }}</div>
|
||||
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
|
||||
<div class="pj-project-meta">
|
||||
{% if p.children %}<span class="pj-chip">서브 {{ p.children|length }}</span>{% endif %}
|
||||
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 내 업무 -->
|
||||
<aside class="pj-home-side">
|
||||
<div class="pj-section-head"><h2>내 업무</h2></div>
|
||||
{% if not my_tasks %}
|
||||
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
|
||||
{% else %}
|
||||
<ul class="pj-mytask-list">
|
||||
{% for t in my_tasks %}
|
||||
<li class="pj-mytask {% if t.completed_at %}is-done{% endif %}">
|
||||
<a href="/project/p/{{ t.project_id }}">
|
||||
<span class="pj-mytask-dot" style="background: {{ t.project_color }}"></span>
|
||||
<span class="pj-mytask-title">{{ t.title }}</span>
|
||||
<span class="pj-mytask-meta">
|
||||
{{ t.project_name }}{% if t.due_date %} · {{ t.due_date }}{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 프로젝트 모달 -->
|
||||
{% if is_admin %}
|
||||
<div class="pj-modal" id="pj-modal-project" hidden>
|
||||
<div class="pj-modal-card">
|
||||
<h3>새 프로젝트</h3>
|
||||
<label>이름<input type="text" id="pj-f-name" placeholder="프로젝트 이름" /></label>
|
||||
<label>설명<textarea id="pj-f-desc" rows="2"></textarea></label>
|
||||
<div class="pj-form-row">
|
||||
<label>시작일<input type="date" id="pj-f-start" /></label>
|
||||
<label>마감일<input type="date" id="pj-f-due" /></label>
|
||||
</div>
|
||||
<label>색상
|
||||
<span class="pj-color-palette" id="pj-f-colors"></span>
|
||||
</label>
|
||||
<div class="pj-modal-actions">
|
||||
<button type="button" class="pj-btn" data-close>취소</button>
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||
</script>
|
||||
<script src="/static/project.js?v=20260625a" defer></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,166 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
|
||||
<!-- FullCalendar (달력) · vis-timeline (타임라인) — 스켈레톤 단계는 CDN, 추후 self-host -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pj-wrap pj-project-view"
|
||||
data-project-id="{{ project.id }}"
|
||||
data-can-manage="{{ 'true' if can_manage else 'false' }}"
|
||||
data-is-admin="{{ 'true' if is_admin else 'false' }}"
|
||||
data-me="{{ user.email }}">
|
||||
|
||||
<div class="pj-layout">
|
||||
<!-- ── 좌측: 서브프로젝트 + 멤버 ── -->
|
||||
<aside class="pj-side">
|
||||
<div class="pj-side-block">
|
||||
<div class="pj-side-head">
|
||||
<span>서브프로젝트</span>
|
||||
{% if can_manage %}
|
||||
<button type="button" class="pj-icon-btn" id="pj-new-sub" title="서브프로젝트 추가">+</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="pj-sub-list" id="pj-sub-list">
|
||||
{% for s in subprojects %}
|
||||
<li>
|
||||
<a href="/project/p/{{ s.id }}">
|
||||
<span class="pj-sub-dot" style="background: {{ s.color }}"></span>{{ s.name }}
|
||||
</a>
|
||||
{% if can_manage %}
|
||||
<button type="button" class="pj-icon-btn pj-del-sub" data-id="{{ s.id }}" title="삭제">×</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="pj-empty-sm">없음</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pj-side-block">
|
||||
<div class="pj-side-head">
|
||||
<span>멤버</span>
|
||||
{% if is_admin %}
|
||||
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="pj-member-list" id="pj-member-list">
|
||||
{% for m in members %}
|
||||
<li>
|
||||
<span class="pj-avatar">{{ (m.user_name or m.user_email)[0] | upper }}</span>
|
||||
<span class="pj-member-name">{{ m.user_name or m.user_email }}</span>
|
||||
{% if m.role == 'manager' %}<span class="pj-chip pj-chip-sm">관리</span>{% endif %}
|
||||
{% if is_admin %}
|
||||
<button type="button" class="pj-icon-btn pj-del-member" data-email="{{ m.user_email }}" title="제외">×</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="pj-empty-sm">없음</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── 우측: 뷰 ── -->
|
||||
<section class="pj-main">
|
||||
<div class="pj-toolbar">
|
||||
<div class="pj-view-tabs" id="pj-view-tabs">
|
||||
<button type="button" class="pj-tab is-active" data-view="calendar">달력</button>
|
||||
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
|
||||
<button type="button" class="pj-tab" data-view="board">보드</button>
|
||||
<button type="button" class="pj-tab" data-view="list">리스트</button>
|
||||
</div>
|
||||
{% if can_manage %}
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 달력 -->
|
||||
<div class="pj-view" data-view="calendar">
|
||||
<div id="pj-calendar"></div>
|
||||
</div>
|
||||
<!-- 타임라인 -->
|
||||
<div class="pj-view" data-view="timeline" hidden>
|
||||
<div id="pj-timeline"></div>
|
||||
</div>
|
||||
<!-- 보드(칸반) -->
|
||||
<div class="pj-view" data-view="board" hidden>
|
||||
<div class="pj-board" id="pj-board"></div>
|
||||
</div>
|
||||
<!-- 리스트 -->
|
||||
<div class="pj-view" data-view="list" hidden>
|
||||
<table class="pj-table" id="pj-list">
|
||||
<thead>
|
||||
<tr><th>업무</th><th>담당자</th><th>단계</th><th>우선순위</th><th>시작</th><th>마감</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업무 모달 -->
|
||||
<div class="pj-modal" id="pj-modal-task" hidden>
|
||||
<div class="pj-modal-card">
|
||||
<h3 id="pj-task-modal-title">새 업무</h3>
|
||||
<input type="hidden" id="pj-t-id" />
|
||||
<label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label>
|
||||
<label>설명<textarea id="pj-t-desc" rows="2"></textarea></label>
|
||||
<div class="pj-form-row">
|
||||
<label>담당자
|
||||
<select id="pj-t-assignee">
|
||||
<option value="">(미지정)</option>
|
||||
{% for m in members %}
|
||||
<option value="{{ m.user_email }}" data-name="{{ m.user_name or m.user_email }}">
|
||||
{{ m.user_name or m.user_email }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>단계
|
||||
<select id="pj-t-stage">
|
||||
{% for s in stages %}
|
||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pj-form-row">
|
||||
<label>우선순위
|
||||
<select id="pj-t-priority">
|
||||
<option value="low">낮음</option>
|
||||
<option value="normal" selected>보통</option>
|
||||
<option value="high">높음</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>시작일<input type="date" id="pj-t-start" /></label>
|
||||
<label>마감일<input type="date" id="pj-t-due" /></label>
|
||||
</div>
|
||||
<div class="pj-modal-actions">
|
||||
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-task" hidden>삭제</button>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="pj-btn" data-close>취소</button>
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-task">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 데이터 -->
|
||||
<script id="pj-data" type="application/json">
|
||||
{
|
||||
"project": {{ project | tojson }},
|
||||
"stages": {{ stages | tojson }},
|
||||
"tasks": {{ tasks | tojson }},
|
||||
"members": {{ members | tojson }},
|
||||
"priorityLabels": {{ priority_labels | tojson }}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/standalone/umd/vis-timeline-graph2d.min.js"></script>
|
||||
<script src="/static/project.js?v=20260625a" defer></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user