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:
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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_내_정보",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 서버
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user