003d29d349
- 좌측 프로젝트 리스트 드래그 재정렬(관리자, 홈 카드와 동일 API) - 세션 추가/편집 팝업: 이름 + 아사나 10색 색상표(project_stages.color, 마이그레이션 004) - 보드 세션 hover 아이콘 확대, 세션 제목 강조(업무 카드보다 크고 굵게) - 완료 세션 반투명 + 보드 끝으로(정렬 기준만 변경 — 해제 시 원래 순서 자동 복귀) - 전체 멤버 업무 팝업에 표 헤더(가운데정렬/굵게) + 정렬 아이콘 + 더블클릭 편집 - 댓글 채팅 팝업을 initCommentsChat() 공용화 — project.html에 모달이 없어 "내 업무" 패널 댓글 더블클릭이 전혀 동작 안 했던 버그 수정(동적 배지 재바인딩 누락도 같이 수정) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""프로젝트 관리 모듈 — 상수 / 기본값 / 순수 검증 로직.
|
|
|
|
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
|
|
)
|
|
|
|
# 세션(칸반 컬럼) 색상 팔레트 — 아사나 태그/커스텀필드에서 쓰는 10색.
|
|
# 세션 생성/편집 팝업의 색상표가 이 순서 그대로 노출된다.
|
|
STAGE_COLOR_PALETTE: tuple[str, ...] = (
|
|
"#4573d2", # blue
|
|
"#37a3a3", # teal
|
|
"#62a420", # green
|
|
"#e8a33d", # amber
|
|
"#e8384f", # red
|
|
"#aa62e3", # purple
|
|
"#f06a6a", # coral
|
|
"#5a6772", # slate
|
|
"#e8398a", # pink
|
|
"#a15c43", # brown
|
|
)
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 대한민국 국경일·공휴일(2026~2030) — 프로젝트 달력 표시 전용.
|
|
#
|
|
# vacation 모듈도 공휴일을 관리하지만(vacation_holidays 테이블) 그건
|
|
# 휴가 일수 계산용으로 vacation_db 에 강하게 결합돼 있고, 여기는 달력에
|
|
# 빨간 글씨로 "보여만" 주면 되는 용도라 별도 DB 연결 없이 정적 표로 둔다.
|
|
# 근로자의 날(5/1)·제헌절(7/17)은 관공서 공휴일이 아니라서 제외했다
|
|
# (vacation 모듈의 기존 2026년 시드와 동일 기준). 대체공휴일은 실제
|
|
# "관공서의 공휴일에 관한 규정" 대체공휴일 규정을 그대로 반영함(설날·
|
|
# 추석·어린이날·삼일절/광복절/개천절/한글날·부처님오신날·성탄절 대상,
|
|
# 신정·현충일은 대체 없음). 새 연도가 필요하면 이 표에 추가한다.
|
|
# ════════════════════════════════════════════════════════════
|
|
KR_HOLIDAYS: dict[str, str] = {
|
|
# 2026
|
|
"2026-01-01": "신정",
|
|
"2026-02-16": "설날 연휴", "2026-02-17": "설날", "2026-02-18": "설날 연휴",
|
|
"2026-03-01": "삼일절", "2026-03-02": "삼일절 대체",
|
|
"2026-05-05": "어린이날",
|
|
"2026-05-24": "부처님오신날", "2026-05-25": "부처님오신날 대체",
|
|
"2026-06-06": "현충일",
|
|
"2026-08-15": "광복절", "2026-08-17": "광복절 대체",
|
|
"2026-09-24": "추석 연휴", "2026-09-25": "추석", "2026-09-26": "추석 연휴",
|
|
"2026-09-28": "추석 대체",
|
|
"2026-10-03": "개천절", "2026-10-05": "개천절 대체",
|
|
"2026-10-09": "한글날",
|
|
"2026-12-25": "성탄절",
|
|
# 2027
|
|
"2027-01-01": "신정",
|
|
"2027-02-06": "설날 연휴", "2027-02-07": "설날", "2027-02-08": "설날 연휴",
|
|
"2027-02-09": "설날 대체",
|
|
"2027-03-01": "삼일절",
|
|
"2027-05-05": "어린이날",
|
|
"2027-05-13": "부처님오신날",
|
|
"2027-06-06": "현충일",
|
|
"2027-08-15": "광복절", "2027-08-16": "광복절 대체",
|
|
"2027-09-14": "추석 연휴", "2027-09-15": "추석", "2027-09-16": "추석 연휴",
|
|
"2027-10-03": "개천절", "2027-10-04": "개천절 대체",
|
|
"2027-10-09": "한글날", "2027-10-11": "한글날 대체",
|
|
"2027-12-25": "성탄절", "2027-12-27": "성탄절 대체",
|
|
# 2028
|
|
"2028-01-01": "신정",
|
|
"2028-01-26": "설날 연휴", "2028-01-27": "설날", "2028-01-28": "설날 연휴",
|
|
"2028-03-01": "삼일절",
|
|
"2028-05-02": "부처님오신날",
|
|
"2028-05-05": "어린이날",
|
|
"2028-06-06": "현충일",
|
|
"2028-08-15": "광복절",
|
|
"2028-10-02": "추석 연휴", "2028-10-03": "추석·개천절", "2028-10-04": "추석 연휴",
|
|
"2028-10-05": "개천절 대체",
|
|
"2028-10-09": "한글날",
|
|
"2028-12-25": "성탄절",
|
|
# 2029
|
|
"2029-01-01": "신정",
|
|
"2029-02-12": "설날 연휴", "2029-02-13": "설날", "2029-02-14": "설날 연휴",
|
|
"2029-03-01": "삼일절",
|
|
"2029-05-05": "어린이날", "2029-05-07": "어린이날 대체",
|
|
"2029-05-20": "부처님오신날", "2029-05-21": "부처님오신날 대체",
|
|
"2029-06-06": "현충일",
|
|
"2029-08-15": "광복절",
|
|
"2029-09-21": "추석 연휴", "2029-09-22": "추석", "2029-09-23": "추석 연휴",
|
|
"2029-09-24": "추석 대체",
|
|
"2029-10-03": "개천절",
|
|
"2029-10-09": "한글날",
|
|
"2029-12-25": "성탄절",
|
|
# 2030
|
|
"2030-01-01": "신정",
|
|
"2030-02-02": "설날 연휴", "2030-02-03": "설날", "2030-02-04": "설날 연휴",
|
|
"2030-02-05": "설날 대체",
|
|
"2030-03-01": "삼일절",
|
|
"2030-05-05": "어린이날", "2030-05-06": "어린이날 대체",
|
|
"2030-05-09": "부처님오신날",
|
|
"2030-06-06": "현충일",
|
|
"2030-08-15": "광복절",
|
|
"2030-09-11": "추석 연휴", "2030-09-12": "추석", "2030-09-13": "추석 연휴",
|
|
"2030-10-03": "개천절",
|
|
"2030-10-09": "한글날",
|
|
"2030-12-25": "성탄절",
|
|
}
|
|
|
|
|
|
def holiday_name(iso_date: str) -> str:
|
|
"""해당 날짜(YYYY-MM-DD)가 공휴일이면 이름을, 아니면 빈 문자열을 반환."""
|
|
return KR_HOLIDAYS.get(iso_date, "")
|
|
|
|
|
|
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_stage_color(color: Any) -> str | None:
|
|
"""세션 색상 — 팔레트 밖 값/빈값은 저장 안 함(NULL, 기본 회색 표시)."""
|
|
v = norm_str(color).lower()
|
|
return v if v in STAGE_COLOR_PALETTE else None
|
|
|
|
|
|
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 # <script>/<style> 내부(태그 자체와 별개로 텍스트도 버림)
|
|
|
|
def _emit_start(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if tag in _DESC_RAW_TEXT_TAGS:
|
|
self._raw = True
|
|
return
|
|
if tag not in _DESC_ALLOWED_TAGS:
|
|
return
|
|
allowed = _DESC_ALLOWED_ATTRS.get(tag, frozenset())
|
|
kept: list[str] = []
|
|
for name, value in attrs:
|
|
if name not in allowed:
|
|
continue
|
|
value = value or ""
|
|
if name in ("href", "src") and not _desc_url_is_safe(value):
|
|
continue
|
|
kept.append(f'{name}="{_desc_escape_attr(value)}"')
|
|
attr_text = (" " + " ".join(kept)) if kept else ""
|
|
self.out.append(f"<{tag}{attr_text}>")
|
|
if tag not in _DESC_VOID_TAGS:
|
|
self._open.append(tag)
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
self._emit_start(tag, attrs)
|
|
|
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
self._emit_start(tag, attrs)
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag in _DESC_RAW_TEXT_TAGS:
|
|
self._raw = False
|
|
return
|
|
if tag not in _DESC_ALLOWED_TAGS or tag in _DESC_VOID_TAGS:
|
|
return
|
|
if tag not in self._open:
|
|
return
|
|
# 짝이 안 맞는 깨진 HTML 방어 — 스택에서 같은 태그가 나올 때까지 닫는다.
|
|
while self._open:
|
|
top = self._open.pop()
|
|
self.out.append(f"</{top}>")
|
|
if top == tag:
|
|
break
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if self._raw:
|
|
return
|
|
self.out.append(_desc_escape_text(data))
|
|
|
|
def close(self) -> None:
|
|
super().close()
|
|
while self._open:
|
|
self.out.append(f"</{self._open.pop()}>")
|
|
|
|
|
|
def sanitize_description_html(html: str) -> str:
|
|
"""업무 설명 HTML을 허용 태그만 남기고 정리한다.
|
|
|
|
스크립트·이벤트 속성(onerror 등)·위험 URL 스킴(javascript: 등)을 제거한다.
|
|
저장 직전 항상 이 함수를 거친다 — 화면에서 붙여넣은 이미지/서식은 거의
|
|
그대로 남고, 위험한 부분만 사라진다.
|
|
"""
|
|
parser = _DescriptionSanitizer()
|
|
parser.feed(html or "")
|
|
parser.close()
|
|
return "".join(parser.out).strip()
|