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,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
|
||||
Reference in New Issue
Block a user