feat(B01): 프로젝트 휴지통 — 지움 = 휴지통 · 7 일 뒤 영구 삭제 · 되살리기 (PLAN 592-1 · 요구 454 · 455)

- 대시보드 지움 = 늘 휴지통(deleted_at) · PROJECT_DELETE_HARD_ENABLED 스위치 걷음 → TRASH_RETENTION_DAYS(7)
- 영구 삭제(hard_delete_project) = 휴지통 것만 · FOR UPDATE · 이미 지운 것 False 로 건너뜀 · 감사 기록 동작 이름 받음
- 정리 루프에 purge_expired_trash — GET_LOCK 이름 잠금(두 PC 서버 중 하나만) · 기한 지난 것 PROJECT_PURGE
- API /api/dashboard/trash/projects — 보기 · 되살리기 = 시스템 관리자(전부) · 회사 관리자(자기 회사) · 영구 삭제 = 시스템 관리자만
- 화면 휴지통 — 시스템 관리 › 휴지통 · 회사 관리 › 휴지통(회사 관리자 · 되살리기만) · 지운 사람 = 감사 기록
- 시험 test_592_1_trash 새로 · test_546 메뉴 기대값에 휴지통 · test_573 복제본 지움을 휴지통 거쳐 영구 삭제로(영구 삭제 = 휴지통 것만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 08:31:11 +09:00
co-authored by Claude Opus 5.5
parent 109903227d
commit ac481a0764
15 changed files with 488 additions and 54 deletions
+29 -2
View File
@@ -12,8 +12,6 @@ export interface DashboardUser {
role: "SYSTEM_ADMIN" | "ADMIN" | "USER";
is_master: boolean;
status: string;
/** 서버가 하드 삭제 모드인지 (`PROJECT_DELETE_HARD_ENABLED`). GET /me에서만 채워진다. */
project_delete_hard?: boolean;
}
export interface WorkflowStageState {
@@ -464,6 +462,35 @@ export function deleteProject(projectId: string): Promise<unknown> {
return request(`/dashboard/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
}
/** 휴지통(592) 한 줄 — days_left = 영구 삭제까지 남은 날 */
export interface TrashProject {
id: string;
name: string;
project_kind?: string | null;
company_id?: number | null;
company_name?: string | null;
deleted_at: string;
deleted_by_name?: string | null;
deleted_by_email?: string | null;
days_left: number;
}
export async function fetchTrashProjects(): Promise<TrashProject[]> {
return (await request<{ projects: TrashProject[] }>("/dashboard/trash/projects")).projects;
}
export function restoreTrashProject(projectId: string): Promise<unknown> {
return request(`/dashboard/trash/projects/${encodeURIComponent(projectId)}/restore`, {
method: "POST",
});
}
export function purgeTrashProject(projectId: string): Promise<unknown> {
return request(`/dashboard/trash/projects/${encodeURIComponent(projectId)}`, {
method: "DELETE",
});
}
export function updateDashboardUser(
userId: number,
payload: AdminUpdateUserRequest,
@@ -0,0 +1,74 @@
"""B01_Dashboard 프로젝트 휴지통(592) — 목록 · 되살리기 · 영구 삭제는 common_util_project_delete."""
from typing import Any
import aiomysql
from common_util.common_util_audit import record_audit
from common_util.common_util_project_delete import DELETED_BY_SQL
from config.config_db import get_db_pool
from config.config_system import TRASH_RETENTION_DAYS
def days_left(seconds_left: int | None) -> int:
"""영구 삭제까지 남은 날 — 하루 덜 찼어도 하루로(올림) · 지났으면 0."""
if seconds_left is None or seconds_left <= 0:
return 0
return -(-int(seconds_left) // 86400)
async def list_trash_projects(company_id: int | None = None) -> list[dict[str, Any]]:
"""휴지통 — 최근 지운 것부터 · company_id 면 그 회사 것만."""
where = " AND p.company_id = %s" if company_id is not None else ""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"""SELECT t.*, du.name AS deleted_by_name, du.email AS deleted_by_email
FROM (SELECT p.id, p.name, p.project_kind, p.company_id, c.name AS company_name,
p.deleted_at, {DELETED_BY_SQL} AS deleted_by,
TIMESTAMPDIFF(SECOND, CURRENT_TIMESTAMP,
p.deleted_at + INTERVAL %s DAY) AS seconds_left
FROM projects p LEFT JOIN companies c ON c.id = p.company_id
WHERE p.deleted_at IS NOT NULL{where}) t
LEFT JOIN users du ON du.id = t.deleted_by
ORDER BY t.deleted_at DESC""",
(TRASH_RETENTION_DAYS, company_id)
if company_id is not None
else (TRASH_RETENTION_DAYS,),
)
rows = list(await cursor.fetchall())
for row in rows:
row["days_left"] = days_left(row.pop("seconds_left"))
return rows
async def get_trash_project(project_id: str) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT id, company_id, name FROM projects WHERE id = %s AND deleted_at IS NOT NULL",
(project_id,),
)
return await cursor.fetchone()
async def restore_project(project_id: str, actor_id: int, request: Any | None = None) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin()
await cursor.execute(
"UPDATE projects SET deleted_at = NULL WHERE id = %s AND deleted_at IS NOT NULL",
(project_id,),
)
changed = cursor.rowcount > 0
if changed:
await record_audit(
cursor,
actor_id=actor_id,
action="PROJECT_RESTORE",
resource_type="project",
resource_ref=project_id,
request=request,
)
await connection.commit()
return changed
+5 -7
View File
@@ -11,9 +11,8 @@ from common_util import common_util_calc_sites as calc_sites
from common_util.common_util_audit import record_audit
from common_util.common_util_auth import require_company, require_system_admin, verify_session
from common_util.common_util_email import send_email
from common_util.common_util_project_delete import hard_delete_project
from config.config_db import get_db_pool
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from config.config_system import APP_PUBLIC_BASE_URL
from .B01_Dashboard_Knowledge_Chat import router as knowledge_chat_router
from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address
@@ -58,6 +57,7 @@ from .B01_Dashboard_Repository_Members import (
set_project_members,
)
from .B01_Dashboard_Router_Assets import router as assets_router
from .B01_Dashboard_Router_Trash import router as trash_router
from .B01_Dashboard_Schema import (
AddMemberRequest,
AdminUpdateUserRequest,
@@ -106,8 +106,6 @@ async def dashboard_me(session: dict[str, Any] = Depends(verify_session)):
user = await get_dashboard_me(int(session["user_id"]))
if not user:
raise HTTPException(status_code=404, detail="사용자 정보를 찾을 수 없습니다.")
# 삭제 버튼이 원본까지 지우는 모드인지 화면에 알려 경고 문구를 바꾸게 한다.
user["project_delete_hard"] = PROJECT_DELETE_HARD_ENABLED
return {"status": "success", "user": user}
@@ -418,9 +416,8 @@ async def dashboard_delete_project(
if not can_del:
raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.")
# 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다.
delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project
if not await delete_project(project_id, int(session["user_id"]), request):
# 지움 = 늘 휴지통(592) · 영구 삭제는 휴지통 [영구 삭제] · 기한 지나면 정리 루프.
if not await soft_delete_project(project_id, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"}
@@ -616,4 +613,5 @@ async def write_calc_sites(
router.include_router(assets_router)
router.include_router(trash_router)
router.include_router(knowledge_chat_router)
@@ -0,0 +1,51 @@
"""B01_Dashboard 프로젝트 휴지통 API(592) — 본체 라우터가 include 함.
보기 · 되살리기 = 시스템 관리자(전부) · 회사 관리자(자기 회사) · 영구 삭제 = 시스템 관리자만.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from common_util.common_util_auth import require_system_admin
from common_util.common_util_project_delete import hard_delete_project
from .B01_Dashboard_Repository_Trash import get_trash_project, list_trash_projects, restore_project
from .B01_Dashboard_Scope import _require_company_id, _same_company, require_company_admin
router = APIRouter()
_GONE = "휴지통에 없는 프로젝트입니다."
@router.get("/trash/projects")
async def trash_projects(session: dict[str, Any] = Depends(require_company_admin)):
company_id = None if session["role"] == "SYSTEM_ADMIN" else _require_company_id(session)
return {"status": "success", "projects": await list_trash_projects(company_id)}
@router.post("/trash/projects/{project_id}/restore")
async def trash_restore(
project_id: str,
request: Request,
session: dict[str, Any] = Depends(require_company_admin),
):
project = await get_trash_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=_GONE)
if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, project.get("company_id")):
raise HTTPException(status_code=403, detail="다른 회사의 프로젝트는 되살릴 수 없습니다.")
if not await restore_project(project_id, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail=_GONE)
return {"status": "success"}
@router.delete("/trash/projects/{project_id}")
async def trash_purge(
project_id: str,
request: Request,
session: dict[str, Any] = Depends(require_system_admin),
):
if not await hard_delete_project(project_id, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail=_GONE)
return {"status": "success"}
+2 -5
View File
@@ -303,13 +303,10 @@ export async function openEditProjectModal(
);
}
export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void {
export function openDeleteProjectModal(_user: DashboardUser, project: ProjectItem): void {
const warning = document.createElement("p");
warning.className = "b01-dashboard__modal-text";
// 하드 삭제 모드에서는 업로드 원본까지 사라진다 — 문구를 바꿔 실수로 날리는 걸 막는다.
warning.textContent = user.project_delete_hard
? L("B01_Dashboard_Confirm_DeleteProject_Hard")
: L("B01_Dashboard_Confirm_DeleteProject");
warning.textContent = L("B01_Dashboard_Confirm_DeleteProject");
openModal(L("B01_Dashboard_DeleteProject"), [warning], async () => {
await deleteProject(project.id);
@@ -16,8 +16,10 @@ const PAGE = 100;
export const ACTION_KO: Record<string, string> = {
PROJECT_CREATE: "프로젝트 생성",
PROJECT_UPDATE: "프로젝트 수정",
PROJECT_DELETE: "프로젝트 삭제",
PROJECT_DELETE: "프로젝트 휴지통으로",
PROJECT_RESTORE: "프로젝트 되살리기",
PROJECT_HARD_DELETE: "프로젝트 영구 삭제",
PROJECT_PURGE: "휴지통 기한 지나 영구 삭제",
COMPANY_CREATE: "회사 생성",
CALC_SITE_CHANGE: "계산 자리 변경",
RATE_ANOMALY: "호출 이상",
@@ -0,0 +1,111 @@
/* =============================================================================
* B01_Dashboard_UI_View_Trash.ts
* 휴지통(592) — 지운 프로젝트 표(이름 · 종류 · 회사 · 지운 사람 · 지운 시각 · 남은 날) + [되살리기] · [영구 삭제].
* 시스템 관리자 = 전부 · 영구 삭제까지 · 회사 관리자 = 자기 회사 것 되살리기만(서버가 거름).
* 남은 날이 다 되면 서버 정리 루프가 DB 행 · 폴더째 지움.
* ========================================================================== */
import { askConfirm } from "@ui/ui_template_modal";
import { showToast } from "@ui/ui_template_toast";
import { createButton } from "@ui/ui_template_elements";
import {
fetchTrashProjects,
purgeTrashProject,
restoreTrashProject,
type TrashProject,
} from "./B01_Dashboard_Api_Fetch";
import { formatDate, formatTime } from "./B01_Dashboard_UI_Common";
import { badge, dataTable, emptyState, nameCell, pageFrame } from "./B01_Dashboard_UI_PageKit";
import { kindBadge } from "./B01_Dashboard_UI_ProjectList_Parts";
import { el as h, loadPage } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const COLUMNS = [
{ label: "이름" },
{ label: "종류" },
{ label: "회사" },
{ label: "지운 사람" },
{ label: "지운 시각" },
{ label: "남은 날" },
{ label: "" },
];
export function mountTrash(host: HTMLElement, ctx: ViewCtx): void {
const admin = ctx.role === "SYSTEM_ADMIN";
const run = (): void =>
loadPage(host, async (stage) => {
const rows = await fetchTrashProjects();
const frame = pageFrame(
stage,
"휴지통",
"지운 프로젝트는 7 일 동안 여기 있다가 자료째 영구 삭제됩니다. 그 전에 되살릴 수 있습니다.",
);
const act = async (
p: TrashProject,
ask: string,
call: (id: string) => Promise<unknown>,
done: string,
danger = false,
): Promise<void> => {
if (!(await askConfirm({ title: p.name, message: ask, danger }))) return;
try {
await call(p.id);
showToast(done, "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "처리하지 못했습니다.", "error");
}
run();
};
const actions = (p: TrashProject): HTMLElement => {
const box = h("div", "b01-sys-tool");
box.append(
createButton({
label: "되살리기",
variant: "ghost",
size: 28,
onClick: () =>
void act(
p,
"프로젝트를 되살려 목록에 다시 보이게 합니다.",
restoreTrashProject,
"되살렸습니다.",
),
}),
);
if (admin)
box.append(
createButton({
label: "영구 삭제",
variant: "danger-ghost",
size: 28,
onClick: () =>
void act(
p,
"DB 자료와 프로젝트 폴더를 지금 영구 삭제합니다. 되돌릴 수 없습니다.",
purgeTrashProject,
"영구 삭제했습니다.",
true,
),
}),
);
return box;
};
frame.body.append(
rows.length
? dataTable(
COLUMNS,
rows.map((p) => [
p.name,
kindBadge(p.project_kind),
p.company_name ?? "-",
nameCell(p.deleted_by_name ?? "-", p.deleted_by_email),
`${formatDate(p.deleted_at)} ${formatTime(p.deleted_at)}`.trim(),
badge(`${p.days_left} 일`, p.days_left <= 1 ? "danger" : "neutral"),
actions(p),
]),
)
: emptyState({ icon: "trash", text: "휴지통이 비어 있습니다." }),
);
});
run();
}
+4
View File
@@ -25,6 +25,7 @@ import { mountSysDefaults } from "./B01_Dashboard_UI_View_SysDefaults";
import { mountSysLogs } from "./B01_Dashboard_UI_View_SysLogs";
import { mountSysOverview } from "./B01_Dashboard_UI_View_SysOverview";
import { mountSysUsers } from "./B01_Dashboard_UI_View_SysUsers";
import { mountTrash } from "./B01_Dashboard_UI_View_Trash";
import { mountUiKit } from "@ui/ui_template_ui_kit";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
@@ -65,12 +66,14 @@ export const VIEWS: View[] = [
{ key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople },
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany },
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: mountCompanyDefaults },
{ key: "company-trash", title: "휴지통", group: "company", roles: ["ADMIN"], icon: "trash", mount: mountTrash },
// 시스템 관리
{ key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: mountSysOverview },
{ key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: mountSysCompanies },
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: mountSysUsers },
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: mountSysDefaults },
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: mountSysLogs },
{ key: "sys-trash", title: "휴지통", group: "system", roles: ["SYSTEM_ADMIN"], icon: "trash", mount: mountTrash },
{ key: "ui-kit", title: "부품 모음", group: "system", roles: ["SYSTEM_ADMIN"], icon: "adjustments", mount: mountUiKit },
// 도움
{ key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide },
@@ -94,6 +97,7 @@ export const VIEW_HELP: Record<string, string> = {
"sys-users": "09_시스템_관리",
"sys-defaults": "09_시스템_관리",
"sys-logs": "09_시스템_관리",
"sys-trash": "09_시스템_관리",
chat: "11_지식_챗봇",
me: "12_내_정보",
};
+69 -9
View File
@@ -1,9 +1,8 @@
"""프로젝트 하드 삭제 — DB 행과 영구저장소 폴더를 실제로 지운다.
"""프로젝트 영구 삭제 — DB 행과 영구저장소 폴더를 실제로 지운다.
평소(배포)에는 쓰이지 않는다. `PROJECT_DELETE_HARD_ENABLED`가 켜진 개발 PC에서만
대시보드 삭제 버튼이 이리로 온다. 소프트 삭제는 `B01_Dashboard_Repository`에 그대로 있다.
`B01_Dashboard_Repository.py`가 이미 700줄 제한을 넘긴 상태라 여기에 따로 둔다.
대시보드 지움은 늘 휴지통(`B01_Dashboard_Repository.soft_delete_project`)으로 간다. 여기로
오는 길은 둘 — 휴지통 [영구 삭제](시스템 관리자) · `TRASH_RETENTION_DAYS` 지난 휴지통을
정리 루프가 비움(`purge_expired_trash`).
"""
import logging
@@ -13,12 +12,18 @@ from typing import Any
from common_util.common_util_audit import record_audit
from common_util.common_util_storage import resolve_project_root_for_delete
from config.config_db import get_db_pool
from config.config_system import TRASH_RETENTION_DAYS
logger = logging.getLogger(__name__)
async def hard_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool:
"""프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다.
async def hard_delete_project(
project_id: str,
actor_id: int,
request: Any | None = None,
action: str = "PROJECT_HARD_DELETE",
) -> bool:
"""휴지통의 프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다.
자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부
`ON DELETE CASCADE`라 행 하나를 지우면 연쇄로 사라진다(`db_management/001_create_schema.sql`).
@@ -31,8 +36,11 @@ async def hard_delete_project(project_id: str, actor_id: int, request: Any | Non
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin()
# 휴지통에 든 것만 · FOR UPDATE — 두 PC 서버가 같은 행을 같이 지우거나 그 사이 되살리면
# 뒤엣것은 기다렸다가 「없음」으로 빠짐.
await cursor.execute(
"SELECT storage_path FROM projects WHERE id = %s AND deleted_at IS NULL",
"""SELECT storage_path FROM projects
WHERE id = %s AND deleted_at IS NOT NULL FOR UPDATE""",
(project_id,),
)
row = await cursor.fetchone()
@@ -65,7 +73,7 @@ async def hard_delete_project(project_id: str, actor_id: int, request: Any | Non
await record_audit(
cursor,
actor_id=actor_id,
action="PROJECT_HARD_DELETE",
action=action,
resource_type="project",
resource_ref=project_id,
request=request,
@@ -87,3 +95,55 @@ async def hard_delete_project(project_id: str, actor_id: int, request: Any | Non
else:
logger.info("하드 삭제 완료: project_id=%s 폴더=%s", project_id, project_root)
return True
# 지운 사람 = 마지막 「휴지통으로」 감사 기록 · 없으면 시스템 관리자 한 명(감사 기록 user_id 필수).
DELETED_BY_SQL = """COALESCE(
(SELECT a.user_id FROM system_audit_logs a
WHERE a.action = 'PROJECT_DELETE' AND a.resource_ref = p.id
ORDER BY a.id DESC LIMIT 1),
(SELECT MIN(su.id) FROM users su WHERE su.role = 'SYSTEM_ADMIN'))"""
_PURGE_LOCK = "aislo_trash_purge"
async def list_expired_trash(
cursor: Any, days: int = TRASH_RETENTION_DAYS
) -> list[tuple[str, int]]:
"""휴지통에 `days` 일 넘게 있은 프로젝트 → (id, 지운 사람)."""
await cursor.execute(
f"""SELECT p.id, {DELETED_BY_SQL} FROM projects p
WHERE p.deleted_at IS NOT NULL
AND p.deleted_at <= CURRENT_TIMESTAMP - INTERVAL %s DAY
ORDER BY p.deleted_at""",
(days,),
)
return [(str(row[0]), int(row[1])) for row in await cursor.fetchall()]
async def purge_expired_trash() -> int:
"""기한 지난 휴지통을 영구 삭제 · 지운 수.
두 PC 서버가 DB 하나를 같이 씀 → 이름 잠금(GET_LOCK)을 못 잡으면 이번 차례는 건너뜀 ·
그 사이 남이 먼저 지운 행은 `hard_delete_project` 가 False 로 건너뜀.
"""
removed = 0
try:
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT GET_LOCK(%s, 0)", (_PURGE_LOCK,))
if (await cursor.fetchone())[0] != 1:
return 0
try:
for project_id, actor_id in await list_expired_trash(cursor):
try:
if await hard_delete_project(project_id, actor_id, action="PROJECT_PURGE"):
removed += 1
except ValueError:
pass # 경로가 안전하지 않음 — hard_delete_project 가 로그를 남김 · 다음 것
finally:
await cursor.execute("SELECT RELEASE_LOCK(%s)", (_PURGE_LOCK,))
except Exception:
logger.exception("휴지통 정리 실패")
if removed:
logger.info("휴지통 정리: %d건 영구 삭제 (보관 %d일)", removed, TRASH_RETENTION_DAYS)
return removed
+3 -1
View File
@@ -15,6 +15,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
list_expired_temp_batches,
)
from common_util.common_util_audit import purge_expired_audit_logs
from common_util.common_util_project_delete import purge_expired_trash
from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root
from config.config_db import get_db_pool
from config.config_system import (
@@ -82,6 +83,7 @@ async def cleanup_expired_temp_uploads_loop() -> None:
removed = await cleanup_expired_temp_uploads()
if removed:
logger.info("임시 보관함 정리 완료: %d건 삭제", removed)
# 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다.
# 시스템 로그 보관 기간 · 휴지통 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다.
await purge_expired_audit_logs()
await purge_expired_trash()
await asyncio.sleep(interval_seconds)
+4 -20
View File
@@ -15,27 +15,11 @@ load_dotenv(PROJECT_ROOT / ".env")
os.environ["PYTHONUTF8"] = "1"
# ═════════════════════════════════════════════════════════════════════════
# ⚠ 0. 위험 스위치 — 프로젝트 하드 삭제
# 0. 프로젝트 휴지통
# ═════════════════════════════════════════════════════════════════════════
# 대시보드의 프로젝트 삭제 버튼이 무엇을 지우는지 결정한다. 눈에 띄라고 맨 위에 둔다.
#
# False (배포 기본값)
# 지금까지의 동작. `projects.deleted_at`만 찍어 목록에서 감춘다. 사용자가 올린
# 라이다 원본은 나중에 다른 프로젝트 개발에 재활용할 수 있는 자산이라 남긴다.
#
# True (개발 PC 전용)
# `projects` 행을 실제로 DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다),
# 영구저장소의 `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 통째로 지운다.
# B03 업로드 원본까지 없어진다. 되돌릴 수 없다.
#
# 개발 중에는 프로젝트를 반복 생성·삭제하는데 소프트 삭제만 하면 수십 GB 라이다 원본이
# 디스크에 계속 쌓인다. 정리 잡도 없다. 그래서 개발 PC의 `.env`에서만 True로 켠다
# (2026-08-08 사용자 지시).
#
# .env → PROJECT_DELETE_HARD_ENABLED=True
#
# 기본값이 False라 환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다.
PROJECT_DELETE_HARD_ENABLED = os.getenv("PROJECT_DELETE_HARD_ENABLED", "False").lower() == "true"
# 대시보드 지움 = 휴지통(`projects.deleted_at`) · 이 날수가 지나면 정리 루프가 DB 행과
# `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 영구 삭제(요구 455).
TRASH_RETENTION_DAYS = int(os.getenv("TRASH_RETENTION_DAYS", "7"))
# ─────────────────────────────────────────────────────────────────────────
# 1. FastAPI 서버
+5 -2
View File
@@ -58,7 +58,10 @@ def test_역할별_메뉴():
help_ = ["help:guide", "help:chat"]
user, admin, forest, system = out
assert user == [*work, "company:company", "company:company-defaults", *help_]
assert admin == [*work, "company:people", "company:company", "company:company-defaults", *help_]
assert admin == [
*work, "company:people", "company:company", "company:company-defaults",
"company:company-trash", *help_,
] # fmt: skip
# 산림청 = 받은 예정노선 없음(보내는 쪽)
assert forest == [
"work:home", "work:projects", "work:temp",
@@ -67,7 +70,7 @@ def test_역할별_메뉴():
assert system == [
*work, "company:people", "company:company",
"system:sys-overview", "system:sys-companies", "system:sys-users",
"system:sys-defaults", "system:sys-logs", "system:ui-kit", *help_,
"system:sys-defaults", "system:sys-logs", "system:sys-trash", "system:ui-kit", *help_,
] # fmt: skip
+3
View File
@@ -109,11 +109,14 @@ def _copytree(source: Path, target: Path) -> None:
async def _hard_delete(project_id, actor):
from B01_Dashboard.B01_Dashboard_Repository import soft_delete_project
from common_util.common_util_project_delete import hard_delete_project
from config.config_db import close_db_pool, init_db_pool
await init_db_pool()
try:
# 영구 삭제는 휴지통 것만(592) — 먼저 휴지통으로
await soft_delete_project(project_id, actor)
return await hard_delete_project(project_id, actor)
finally:
await close_db_pool()
+123
View File
@@ -0,0 +1,123 @@
"""592-1 프로젝트 휴지통 지킴 시험.
- 남은 날 = 올림 · 지났으면 0
- 7 일 판정 — 8 일 전 지운 것만 기한 지남 · 6 일 전 · 살아 있는 것은 아님
- 영구 삭제 = 휴지통 것만 · 이미 지운 것은 건너뜀(False) · DB 행 · 폴더 0 · 감사 기록
- 되살리기 = deleted_at 비움
- 정리 잠금 — 남이 잠금을 쥐면 이번 차례 건너뜀
시험 행은 DB 에 바로 넣고(이름 「sub3임시」 · 폴더는 임시 storage) 끝에 행 · 감사 기록째 지움.
"""
import asyncio
import uuid
import pytest
from B01_Dashboard.B01_Dashboard_Repository_Trash import days_left
def test_days_left_rounds_up():
assert [days_left(v) for v in (None, -5, 0, 1, 86400, 86401, 7 * 86400)] == [
0,
0,
0,
1,
1,
2,
7,
]
async def _scenario(storage_root):
from B01_Dashboard.B01_Dashboard_Repository_Trash import restore_project
from common_util.common_util_project_delete import (
_PURGE_LOCK,
hard_delete_project,
list_expired_trash,
purge_expired_trash,
)
from config.config_db import close_db_pool, get_db_pool, init_db_pool
try:
await init_db_pool()
except Exception as error: # DB 없는 PC
pytest.skip(f"DB 연결 안 됨: {error}")
pool = get_db_pool()
ids = {key: str(uuid.uuid4()) for key in ("old", "young", "live")}
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT id, company_id FROM users WHERE role = 'SYSTEM_ADMIN' LIMIT 1")
actor, company = await cursor.fetchone()
try:
async with pool.acquire() as connection, connection.cursor() as cursor:
for key, ago in (("old", 8), ("young", 6), ("live", None)):
pid = ids[key]
(storage_root / "c" / "u" / pid).mkdir(parents=True)
(storage_root / "c" / "u" / pid / "x.txt").write_text("x")
await cursor.execute(
"""INSERT INTO projects (id, user_id, company_id, name, storage_path, deleted_at)
VALUES (%s, %s, %s, %s, %s,
IF(%s IS NULL, NULL, CURRENT_TIMESTAMP - INTERVAL %s DAY))""",
(
pid,
actor,
company or 1,
f"sub3임시_592_{key}",
f"storage/c/u/{pid}",
ago,
ago,
),
)
await connection.commit()
expired = {pid for pid, _ in await list_expired_trash(cursor)}
assert ids["old"] in expired
assert ids["young"] not in expired and ids["live"] not in expired
# 남이 잠금을 쥔 동안은 정리 차례를 건너뜀 — 행 그대로
async with pool.acquire() as holder, holder.cursor() as cursor:
await cursor.execute("SELECT GET_LOCK(%s, 0)", (_PURGE_LOCK,))
assert (await cursor.fetchone())[0] == 1
try:
assert await purge_expired_trash() == 0
finally:
await cursor.execute("SELECT RELEASE_LOCK(%s)", (_PURGE_LOCK,))
# 살아 있는 것은 영구 삭제 안 됨
assert await hard_delete_project(ids["live"], actor) is False
assert (storage_root / "c" / "u" / ids["live"]).is_dir()
# 기한 지난 것 영구 삭제 → 행 · 폴더 0 · 다시 부르면 건너뜀
assert await hard_delete_project(ids["old"], actor, action="PROJECT_PURGE") is True
assert await hard_delete_project(ids["old"], actor, action="PROJECT_PURGE") is False
assert not (storage_root / "c" / "u" / ids["old"]).exists()
# 되살리기
assert await restore_project(ids["young"], actor) is True
assert await restore_project(ids["young"], actor) is False
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT id, deleted_at FROM projects WHERE id IN (%s, %s, %s)", tuple(ids.values())
)
rows = dict(await cursor.fetchall())
await cursor.execute(
"SELECT action FROM system_audit_logs WHERE resource_ref = %s", (ids["old"],)
)
actions = [row[0] for row in await cursor.fetchall()]
assert ids["old"] not in rows
assert rows[ids["young"]] is None
assert actions == ["PROJECT_PURGE"]
finally:
async with pool.acquire() as connection, connection.cursor() as cursor:
marks = ", ".join(["%s"] * len(ids))
await cursor.execute(f"DELETE FROM projects WHERE id IN ({marks})", tuple(ids.values()))
await cursor.execute(
f"DELETE FROM system_audit_logs WHERE resource_ref IN ({marks})",
tuple(ids.values()),
)
await connection.commit()
await close_db_pool()
def test_trash_purge_restore(tmp_path, monkeypatch):
import common_util.common_util_storage as storage
root = tmp_path / "storage"
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(root))
asyncio.run(_scenario(root))
+2 -7
View File
@@ -178,13 +178,8 @@ export const ui_locales_b1 = {
// 확인 메시지
B01_Dashboard_Confirm_DeleteProject: [
"프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.",
"Delete this project? This cannot be undone.",
],
// 하드 삭제 모드(PROJECT_DELETE_HARD_ENABLED=True) 전용 경고
B01_Dashboard_Confirm_DeleteProject_Hard: [
"[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?",
"[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?",
"프로젝트를 휴지통으로 옮깁니다. 7 일 안에는 휴지통에서 되살릴 수 있고, 7 일이 지나면 자료째 영구 삭제됩니다.",
"Move this project to the trash? It can be restored from the Trash within 7 days; after that it is permanently deleted with all its data.",
],
B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"],
B01_Dashboard_Confirm_LastAdmin: [