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