"""프로젝트 관리 모듈 — 상수 / 기본값 / 순수 검증 로직. DB I/O 는 db.py(ProjectStore)가 담당하고, 여기서는 상수와 입력 검증 등 DB 없이 단위 테스트 가능한 순수 함수만 둔다(malaysia.store 패턴). """ from __future__ import annotations from html.parser import HTMLParser from typing import Any # 참고용 기본 세션(칸반 컬럼) 이름 목록 — 프로젝트 생성 시 자동으로 만들지는 # 않는다(세션은 아사나처럼 사용자가 직접 추가한다). 보드 컬럼 정렬 시 이 # 이름들을 우선순위로만 쓴다(router.py `_order`). # (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", "completed", "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 validate_time(t: Any) -> str | None: """'HH:MM' 형식만 통과(초는 버림). 빈값/None 은 None(=종일).""" v = norm_str(t) if not v: return None parts = v.split(":") try: hh = int(parts[0]) mm = int(parts[1]) if len(parts) > 1 else 0 except (ValueError, IndexError): raise ValueError(f"시간 형식이 올바르지 않습니다: {t}") if not (0 <= hh <= 23 and 0 <= mm <= 59): raise ValueError(f"시간 범위가 올바르지 않습니다: {t}") return f"{hh:02d}:{mm:02d}" 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}" # ════════════════════════════════════════════════════════════ # 업무 설명 HTML 정리 (이미지 붙여넣기/업로드 삽입 지원) # # 업무 편집창의 설명란은 contenteditable 이라 브라우저가 임의의 HTML을 # 만들어 보낼 수 있다. 여기서 허용 태그만 남기고 스크립트/이벤트 속성/위험 # URL 스킴을 제거한다 — 외부 라이브러리(bleach 등) 없이 표준 라이브러리 # html.parser 만으로 구현한다(카페24 모듈 자체호스팅 원칙과 동일). # # 기존에 일반 텍스트로 저장돼 있던 설명은 마이그레이션(project_db_003)에서 # 한 번에 안전한 HTML로 변환해뒀다 — 여기서는 "이게 HTML이냐 텍스트냐"를 # 런타임에 추측하지 않는다(추측은 오탐이 난다). description 컬럼은 이제 # 항상 HTML이라고 가정한다. # ════════════════════════════════════════════════════════════ _DESC_ALLOWED_TAGS: frozenset[str] = frozenset({ "p", "br", "b", "strong", "i", "em", "u", "a", "img", "ul", "ol", "li", "blockquote", "code", "pre", "span", "div", }) _DESC_VOID_TAGS: frozenset[str] = frozenset({"br", "img"}) _DESC_ALLOWED_ATTRS: dict[str, frozenset[str]] = { "a": frozenset({"href"}), "img": frozenset({"src", "alt", "width", "height"}), } _DESC_DANGEROUS_URL_PREFIXES: tuple[str, ...] = ("javascript:", "vbscript:", "data:text/html") # script/style 은 태그 자체는 물론 그 안의 텍스트(코드)도 통째로 버린다 — # 허용 태그 목록에 없어 태그는 이미 안 남지만, HTMLParser는 이 둘의 내용을 # 별도 데이터로 넘기므로 handle_data 에서 따로 걸러야 한다. _DESC_RAW_TEXT_TAGS: frozenset[str] = frozenset({"script", "style"}) def _desc_escape_text(text: str) -> str: return text.replace("&", "&").replace("<", "<").replace(">", ">") def _desc_escape_attr(value: str) -> str: return _desc_escape_text(value).replace('"', """) def _desc_url_is_safe(value: str) -> bool: v = value.strip().lower() return not any(v.startswith(prefix) for prefix in _DESC_DANGEROUS_URL_PREFIXES) class _DescriptionSanitizer(HTMLParser): """허용 태그/속성만 통과시키는 최소 HTML 정리기.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.out: list[str] = [] self._open: list[str] = [] self._raw = False #