feat(B01,B07): 담당자 회사 구성원 지정 + 로고·서명 회사 공유 자산 모달
- B01 프로젝트 수정: 과업책임자·분야별책임자·설계자를 회사 구성원 목록(select)에서 지정. 회사 로고·설계자 서명 칸은 누르면 등록 목록 모달(선택·삭제·신규 추가 업로드). - 백엔드: `company_assets`(013) CRUD 라우트 신설 — 목록·업로드(Form+File, 2MB·확장자 검사)·수정·소프트 삭제·파일 서빙. `_scope_company` 로 SYSTEM_ADMIN 만 타 회사 지정, `_check_project_refs` 로 담당자·자산이 같은 회사·같은 종류인지 검사(400). - projects 조회·수정에 pm/field_lead/designer/logo_asset/signature_asset 5칸 합류. 중복 3곳을 `_project_rows` 로 묶어 700줄 제한 유지. - B07 표제란: 로고·서명 출처를 `companies.logo_path`/`users.signature_path` 에서 프로젝트가 고른 `company_assets` 로 전환(삭제된 자산은 그림째 제외). - 검증: pytest 143 통과(신규 4), tsc·ruff·prettier 통과. 공용 브라우저 사용자 경로 (업로드→선택→저장→재열기) 및 종단도 API 표제란 글자 4건·그림 2건 수치 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,12 @@ export interface ProjectItem {
|
||||
project_number?: string | null;
|
||||
work_amount?: string | null;
|
||||
design_date?: string | null;
|
||||
/** 담당자는 회사 구성원(users.id), 로고·서명은 회사 공유 자산(company_assets.id) */
|
||||
pm_user_id?: number | null;
|
||||
field_lead_user_id?: number | null;
|
||||
designer_user_id?: number | null;
|
||||
logo_asset_id?: number | null;
|
||||
signature_asset_id?: number | null;
|
||||
owner_name?: string | null;
|
||||
workflow_stage: number;
|
||||
progress_percent: number;
|
||||
@@ -76,6 +82,16 @@ export interface Member {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** 회사 안에서 공유하는 도면 자산(로고·서명). user_id 가 없으면 회사 공용. */
|
||||
export interface CompanyAsset {
|
||||
id: number;
|
||||
company_id: number;
|
||||
kind: "LOGO" | "SIGNATURE";
|
||||
label: string;
|
||||
user_id?: number | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: number;
|
||||
user_id: number;
|
||||
@@ -141,6 +157,11 @@ export interface UpdateProjectRequest {
|
||||
project_number?: string | null;
|
||||
work_amount?: string | null;
|
||||
design_date?: string | null;
|
||||
pm_user_id?: number | null;
|
||||
field_lead_user_id?: number | null;
|
||||
designer_user_id?: number | null;
|
||||
logo_asset_id?: number | null;
|
||||
signature_asset_id?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminUpdateUserRequest extends UpdateUserRequest {
|
||||
@@ -226,11 +247,43 @@ export function joinCompany(companyId: number): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyMembers(): Promise<Member[]> {
|
||||
const data = await request<{ members: Member[] }>("/dashboard/admin/members");
|
||||
const companyQuery = (companyId?: number | null): string =>
|
||||
companyId ? `?company_id=${encodeURIComponent(companyId)}` : "";
|
||||
|
||||
/** companyId 는 시스템관리자가 남의 회사 프로젝트를 고칠 때만 넘긴다. */
|
||||
export async function fetchCompanyMembers(companyId?: number | null): Promise<Member[]> {
|
||||
const data = await request<{ members: Member[] }>(
|
||||
`/dashboard/admin/members${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function fetchCompanyAssets(companyId?: number | null): Promise<CompanyAsset[]> {
|
||||
const data = await request<{ assets: CompanyAsset[] }>(
|
||||
`/dashboard/company/assets${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.assets;
|
||||
}
|
||||
|
||||
/** multipart 업로드라 `request()`의 JSON 헤더를 쓰지 않는다. */
|
||||
export async function createCompanyAsset(form: FormData): Promise<number> {
|
||||
const response = await fetch(`${API_BASE_URL}/dashboard/company/assets`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
const data = (await response.json()) as { detail?: string; asset_id: number };
|
||||
if (!response.ok) throw new Error(data.detail ?? "Request failed");
|
||||
return data.asset_id;
|
||||
}
|
||||
|
||||
export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
||||
return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export const companyAssetFileUrl = (assetId: number): string =>
|
||||
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
|
||||
|
||||
export function addCompanyMember(email: string): Promise<unknown> {
|
||||
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ def _project_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**row, "workflow_stage": stage, "progress_percent": progress}
|
||||
|
||||
|
||||
async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows])
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
|
||||
|
||||
async def get_dashboard_me(user_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
@@ -149,20 +159,14 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id, name, region, road_type, project_year,
|
||||
estimated_length_m, memo, status, updated_at, created_at,
|
||||
client_org, project_number, work_amount, design_date
|
||||
client_org, project_number, work_amount, design_date,
|
||||
pm_user_id, field_lead_user_id, designer_user_id,
|
||||
logo_asset_id, signature_asset_id
|
||||
FROM projects WHERE user_id = %s AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC, created_at DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
pids = [r["id"] for r in rows]
|
||||
states = await get_workflow_states_for_projects(cursor, pids)
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
return await _project_rows(cursor, await cursor.fetchall())
|
||||
|
||||
|
||||
async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
@@ -172,21 +176,15 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
|
||||
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
|
||||
p.client_org, p.project_number, p.work_amount, p.design_date,
|
||||
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
|
||||
p.logo_asset_id, p.signature_asset_id,
|
||||
u.name AS owner_name, u.email AS owner_email
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.company_id = %s AND p.deleted_at IS NULL
|
||||
ORDER BY p.updated_at DESC, p.created_at DESC""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
pids = [r["id"] for r in rows]
|
||||
states = await get_workflow_states_for_projects(cursor, pids)
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
return await _project_rows(cursor, await cursor.fetchall())
|
||||
|
||||
|
||||
async def list_all_projects() -> list[dict[str, Any]]:
|
||||
@@ -196,20 +194,14 @@ async def list_all_projects() -> list[dict[str, Any]]:
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
|
||||
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
|
||||
p.client_org, p.project_number, p.work_amount, p.design_date,
|
||||
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
|
||||
p.logo_asset_id, p.signature_asset_id,
|
||||
u.name AS owner_name, u.email AS owner_email
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
ORDER BY p.updated_at DESC, p.created_at DESC"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
pids = [r["id"] for r in rows]
|
||||
states = await get_workflow_states_for_projects(cursor, pids)
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
return await _project_rows(cursor, await cursor.fetchall())
|
||||
|
||||
|
||||
async def get_project(project_id: str) -> dict[str, Any] | None:
|
||||
@@ -218,7 +210,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
|
||||
await cursor.execute(
|
||||
"""SELECT id, user_id, company_id, name, region, road_type, project_year,
|
||||
estimated_length_m, memo, status,
|
||||
client_org, project_number, work_amount, design_date
|
||||
client_org, project_number, work_amount, design_date,
|
||||
pm_user_id, field_lead_user_id, designer_user_id,
|
||||
logo_asset_id, signature_asset_id
|
||||
FROM projects WHERE id = %s AND deleted_at IS NULL""",
|
||||
(project_id,),
|
||||
)
|
||||
@@ -234,7 +228,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
|
||||
SET name = %s, region = %s, road_type = %s, project_year = %s,
|
||||
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status),
|
||||
client_org = %s, project_number = %s, work_amount = %s,
|
||||
design_date = %s
|
||||
design_date = %s, pm_user_id = %s, field_lead_user_id = %s,
|
||||
designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(
|
||||
data["name"],
|
||||
@@ -248,6 +243,11 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
|
||||
data.get("project_number"),
|
||||
data.get("work_amount"),
|
||||
data.get("design_date"),
|
||||
data.get("pm_user_id"),
|
||||
data.get("field_lead_user_id"),
|
||||
data.get("designer_user_id"),
|
||||
data.get("logo_asset_id"),
|
||||
data.get("signature_asset_id"),
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""B01_Dashboard 회사 공유 도면 자산(로고·서명) 저장소 — `company_assets` (013).
|
||||
|
||||
그림은 `storage/{회사}/assets/` 에 파일로 두고 표에는 경로만 담는다. 자산은 사용자 계정에
|
||||
물릴 수도(개인 서명) 아닐 수도(회사 공용 직인) 있다 — `user_id` NULL 이 공용.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
|
||||
ASSET_KINDS = ("LOGO", "SIGNATURE")
|
||||
ASSET_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
ASSET_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
async def list_company_assets(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT a.id, a.company_id, a.kind, a.label, a.file_path, a.user_id,
|
||||
u.name AS user_name, a.created_at
|
||||
FROM company_assets a LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.company_id = %s AND a.deleted_at IS NULL
|
||||
ORDER BY a.kind, a.label, a.id""",
|
||||
(company_id,),
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def get_company_asset(asset_id: int) -> 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, kind, label, file_path, user_id
|
||||
FROM company_assets WHERE id = %s AND deleted_at IS NULL""",
|
||||
(asset_id,),
|
||||
)
|
||||
return await cursor.fetchone()
|
||||
|
||||
|
||||
def write_company_asset_file(company_id: int, kind: str, suffix: str, blob: bytes) -> str:
|
||||
"""그림을 `storage/{회사}/assets/` 에 쓰고 storage 기준 상대 경로를 돌려준다."""
|
||||
folder = os.path.join(STORAGE_BASE_DIR, str(company_id), "assets")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
filename = f"{kind.lower()}_{uuid.uuid4().hex}{suffix}"
|
||||
with open(os.path.join(folder, filename), "wb") as file:
|
||||
file.write(blob)
|
||||
return f"storage/{company_id}/assets/{filename}"
|
||||
|
||||
|
||||
async def create_company_asset(
|
||||
company_id: int, kind: str, label: str, file_path: str, user_id: int | None, actor_id: int
|
||||
) -> int:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO company_assets (company_id, kind, label, file_path, user_id, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(company_id, kind, label, file_path, user_id, actor_id),
|
||||
)
|
||||
await connection.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
async def update_company_asset(asset_id: int, label: str, user_id: int | None) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE company_assets SET label = %s, user_id = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(label, user_id, asset_id),
|
||||
)
|
||||
await connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def delete_company_asset(asset_id: int) -> bool:
|
||||
"""소프트 삭제 — 프로젝트가 물고 있던 참조는 도면 읽을 때 LEFT JOIN 으로 빈칸이 된다."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE company_assets SET deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(asset_id,),
|
||||
)
|
||||
await connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
@@ -1,11 +1,14 @@
|
||||
"""B01_Dashboard 역할별 대시보드 API."""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
from common_util.common_util_project_delete import hard_delete_project
|
||||
from common_util.common_util_storage import read_stored_asset
|
||||
from config.config_system import PROJECT_DELETE_HARD_ENABLED
|
||||
|
||||
from .B01_Dashboard_Repository import (
|
||||
@@ -35,6 +38,17 @@ from .B01_Dashboard_Repository import (
|
||||
update_project,
|
||||
update_user_profile,
|
||||
)
|
||||
from .B01_Dashboard_Repository_Assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_KINDS,
|
||||
ASSET_MAX_BYTES,
|
||||
create_company_asset,
|
||||
delete_company_asset,
|
||||
get_company_asset,
|
||||
list_company_assets,
|
||||
update_company_asset,
|
||||
write_company_asset_file,
|
||||
)
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
@@ -43,6 +57,7 @@ from .B01_Dashboard_Schema import (
|
||||
CreateCompanyRequest,
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UpdateCompanyAssetRequest,
|
||||
UpdateProjectRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
@@ -69,6 +84,43 @@ def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
|
||||
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
|
||||
|
||||
|
||||
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
|
||||
"""회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다."""
|
||||
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
|
||||
return int(company_id)
|
||||
own = _require_company_id(session)
|
||||
if company_id is not None and int(company_id) != own:
|
||||
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
|
||||
return own
|
||||
|
||||
|
||||
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
|
||||
asset = await get_company_asset(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
|
||||
_scope_company(session, int(asset["company_id"]))
|
||||
return asset
|
||||
|
||||
|
||||
async def _check_project_refs(project: dict[str, Any], data: dict[str, Any]) -> None:
|
||||
"""담당자·로고·서명은 프로젝트 회사의 것만 물린다 (2026-09-02 사용자 확정)."""
|
||||
company_id = int(project["company_id"])
|
||||
user_ids = {
|
||||
data[k] for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data[k]
|
||||
}
|
||||
if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}:
|
||||
raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.")
|
||||
wanted = {
|
||||
k: kind
|
||||
for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE"))
|
||||
if data[k]
|
||||
}
|
||||
if wanted:
|
||||
kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)}
|
||||
if any(kinds.get(data[k]) != kind for k, kind in wanted.items()):
|
||||
raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.")
|
||||
|
||||
|
||||
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
return True
|
||||
@@ -142,10 +194,14 @@ async def user_company_join(
|
||||
|
||||
|
||||
@router.get("/admin/members")
|
||||
async def admin_members(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
async def admin_members(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
# company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다.
|
||||
return {
|
||||
"status": "success",
|
||||
"members": await list_company_members(_require_company_id(session)),
|
||||
"members": await list_company_members(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +267,9 @@ async def dashboard_update_project(
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if not _can_edit_project(session, project):
|
||||
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
|
||||
if not await update_project(project_id, payload.model_dump(), int(session["user_id"])):
|
||||
data = payload.model_dump()
|
||||
await _check_project_refs(project, data)
|
||||
if not await update_project(project_id, data, int(session["user_id"])):
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
@@ -352,3 +410,77 @@ async def system_resources(
|
||||
async def system_projects(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "projects": await list_all_projects()}
|
||||
|
||||
|
||||
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
|
||||
|
||||
|
||||
@router.get("/company/assets")
|
||||
async def company_assets(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
return {
|
||||
"status": "success",
|
||||
"assets": await list_company_assets(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/company/assets")
|
||||
async def company_add_asset(
|
||||
kind: str = Form(...),
|
||||
label: str = Form(...),
|
||||
user_id: int | None = Form(None, gt=0),
|
||||
company_id: int | None = Form(None, gt=0),
|
||||
file: UploadFile = File(...),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
kind = kind.upper()
|
||||
if kind not in ASSET_KINDS:
|
||||
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
|
||||
label = label.strip()[:100]
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
|
||||
suffix = os.path.splitext(file.filename or "")[1].lower()
|
||||
if suffix not in ASSET_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
|
||||
blob = await file.read()
|
||||
if not blob or len(blob) > ASSET_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
|
||||
scoped = _scope_company(session, company_id)
|
||||
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
|
||||
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
|
||||
path = write_company_asset_file(scoped, kind, suffix, blob)
|
||||
asset_id = await create_company_asset(
|
||||
scoped, kind, label, path, user_id, int(session["user_id"])
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
|
||||
|
||||
@router.put("/company/assets/{asset_id}")
|
||||
async def company_update_asset(
|
||||
asset_id: int,
|
||||
payload: UpdateCompanyAssetRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
await _company_asset(session, asset_id)
|
||||
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.delete("/company/assets/{asset_id}")
|
||||
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
await _company_asset(session, asset_id)
|
||||
await delete_company_asset(asset_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/company/assets/{asset_id}/file")
|
||||
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
||||
asset = await _company_asset(session, asset_id)
|
||||
blob = read_stored_asset(asset["file_path"])
|
||||
if blob is None:
|
||||
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
|
||||
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
|
||||
return Response(content=blob, media_type=media_type)
|
||||
|
||||
@@ -54,9 +54,22 @@ class UpdateProjectRequest(BaseModel):
|
||||
work_amount: str | None = Field(default=None, max_length=100)
|
||||
# 설계일자는 확정일 자동이 아니라 사용자 지정이다 (2026-09-02 사용자 확정).
|
||||
design_date: date | None = None
|
||||
# 담당자는 회사 구성원(users) 중에서, 로고·서명은 회사 공유 자산(company_assets) 중에서
|
||||
# 고른다 — 같은 회사 것인지는 라우터가 확인한다.
|
||||
pm_user_id: int | None = Field(default=None, gt=0)
|
||||
field_lead_user_id: int | None = Field(default=None, gt=0)
|
||||
designer_user_id: int | None = Field(default=None, gt=0)
|
||||
logo_asset_id: int | None = Field(default=None, gt=0)
|
||||
signature_asset_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class AdminUpdateUserRequest(UpdateUserRequest):
|
||||
status: str | None = Field(
|
||||
default=None, pattern="^(NO_COMPANY|PENDING|ACTIVE|INACTIVE|REJECTED)$"
|
||||
)
|
||||
|
||||
|
||||
class UpdateCompanyAssetRequest(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=100)
|
||||
# 사용자 계정에 물릴 수도(개인 서명) 아닐 수도(회사 공용 직인) 있다.
|
||||
user_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 회사 공유 도면 자산(로고·서명) 고르기 (2026-09-02 사용자 확정).
|
||||
*
|
||||
* 프로젝트 수정 모달의 로고·서명 칸을 누르면 등록된 목록을 모달로 보여 주고, 없으면
|
||||
* 그 자리에서 새로 올린다. 자산은 회사 안에서 공유하며 사용자 계정에 물릴 수도(개인 서명)
|
||||
* 아닐 수도(회사 공용 직인) 있다.
|
||||
*/
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
companyAssetFileUrl,
|
||||
createCompanyAsset,
|
||||
deleteCompanyAsset,
|
||||
fetchCompanyAssets,
|
||||
type CompanyAsset,
|
||||
type DashboardUser,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
export interface AssetFieldHandle {
|
||||
root: HTMLDivElement;
|
||||
value: () => number | null;
|
||||
}
|
||||
|
||||
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
|
||||
|
||||
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
|
||||
export function createAssetField(
|
||||
label: string,
|
||||
kind: CompanyAsset["kind"],
|
||||
assets: CompanyAsset[],
|
||||
initialId: number | null | undefined,
|
||||
companyId: number | null | undefined,
|
||||
user: DashboardUser,
|
||||
): AssetFieldHandle {
|
||||
let list = assets.filter((asset) => asset.kind === kind);
|
||||
let selected = list.find((asset) => asset.id === initialId) ?? null;
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-field";
|
||||
const caption = document.createElement("label");
|
||||
caption.className = "ui-field__label";
|
||||
caption.textContent = label;
|
||||
const row = document.createElement("div");
|
||||
row.className = "b01-dashboard__asset-row";
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "b01-dashboard__asset-preview";
|
||||
preview.alt = "";
|
||||
const name = document.createElement("span");
|
||||
name.className = "b01-dashboard__asset-name";
|
||||
|
||||
const render = (): void => {
|
||||
preview.hidden = !selected;
|
||||
if (selected) preview.src = companyAssetFileUrl(selected.id);
|
||||
name.textContent = selected ? selected.label : "(없음)";
|
||||
};
|
||||
render();
|
||||
|
||||
const pick = createButton({
|
||||
label: "선택…",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => {
|
||||
list = fresh;
|
||||
selected = next;
|
||||
render();
|
||||
}),
|
||||
});
|
||||
row.append(preview, name, pick);
|
||||
root.append(caption, row);
|
||||
return { root, value: () => selected?.id ?? null };
|
||||
}
|
||||
|
||||
function openAssetPickerModal(
|
||||
kind: CompanyAsset["kind"],
|
||||
assets: CompanyAsset[],
|
||||
selectedId: number | null,
|
||||
companyId: number | null | undefined,
|
||||
user: DashboardUser,
|
||||
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
|
||||
): void {
|
||||
let list = assets;
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b01-dashboard__modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-dashboard__modal-panel";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "b01-dashboard__modal-title";
|
||||
heading.textContent = `${KIND_LABEL[kind]} 선택`;
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__asset-grid";
|
||||
|
||||
const close = (): void => modal.remove();
|
||||
const choose = (asset: CompanyAsset | null): void => {
|
||||
onPick(asset, list);
|
||||
close();
|
||||
};
|
||||
|
||||
const card = (asset: CompanyAsset | null): HTMLElement => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "b01-dashboard__asset";
|
||||
if ((asset?.id ?? null) === selectedId) item.classList.add("is-selected");
|
||||
item.tabIndex = 0;
|
||||
item.addEventListener("click", () => choose(asset));
|
||||
item.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") choose(asset);
|
||||
});
|
||||
if (asset) {
|
||||
const image = document.createElement("img");
|
||||
image.src = companyAssetFileUrl(asset.id);
|
||||
image.alt = asset.label;
|
||||
const owner = asset.user_name ? asset.user_name : "회사 공용";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${asset.label} · ${owner}`;
|
||||
// 삭제는 두 번 눌러 확정 — 공용 confirm 모달은 이 모달 아래(z-index)에 깔려 못 쓴다.
|
||||
const remove = createButton({ label: "삭제", variant: "ghost" });
|
||||
remove.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
if (remove.textContent !== "정말 삭제?") {
|
||||
remove.textContent = "정말 삭제?";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteCompanyAsset(asset.id);
|
||||
list = list.filter((entry) => entry.id !== asset.id);
|
||||
if (asset.id === selectedId) onPick(null, list);
|
||||
item.remove();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "삭제 실패", "error");
|
||||
}
|
||||
});
|
||||
item.append(image, text, remove);
|
||||
} else {
|
||||
const text = document.createElement("span");
|
||||
text.textContent = "(없음)";
|
||||
item.append(text);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
grid.append(card(null), ...list.map(card));
|
||||
|
||||
// 신규 추가 — 이름 + 그림 파일 + (내 계정에 물릴지)
|
||||
const addTitle = document.createElement("h4");
|
||||
addTitle.className = "b01-dashboard__modal-subtitle";
|
||||
addTitle.textContent = "신규 추가";
|
||||
const label = createInputField({ label: "이름", placeholder: `예: ${KIND_LABEL[kind]} 2026` });
|
||||
const file = createInputField({ label: "그림 파일 (png·jpg·webp·svg, 2MB 이하)" });
|
||||
file.input.type = "file";
|
||||
file.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
||||
const mine = document.createElement("label");
|
||||
mine.className = "b01-dashboard__check";
|
||||
const mineBox = document.createElement("input");
|
||||
mineBox.type = "checkbox";
|
||||
mineBox.checked = kind === "SIGNATURE";
|
||||
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
|
||||
const add = createButton({
|
||||
label: "올리고 선택",
|
||||
onClick: async () => {
|
||||
const chosen = file.input.files?.[0];
|
||||
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
||||
if (!chosen) return file.setError("그림 파일을 고르세요.");
|
||||
const form = new FormData();
|
||||
form.append("kind", kind);
|
||||
form.append("label", label.input.value.trim());
|
||||
form.append("file", chosen);
|
||||
if (mineBox.checked) form.append("user_id", String(user.id));
|
||||
if (companyId) form.append("company_id", String(companyId));
|
||||
try {
|
||||
const id = await createCompanyAsset(form);
|
||||
const fresh = await fetchCompanyAssets(companyId);
|
||||
list = fresh.filter((asset) => asset.kind === kind);
|
||||
choose(list.find((asset) => asset.id === id) ?? null);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "업로드 실패", "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
|
||||
panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
}
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
joinCompany,
|
||||
searchCompanies,
|
||||
addCompanyMember,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyAssets,
|
||||
type DashboardUser,
|
||||
type ProjectItem,
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -66,8 +69,16 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
|
||||
document.body.append(modal);
|
||||
}
|
||||
|
||||
export function openEditProjectModal(user: DashboardUser, project: ProjectItem): void {
|
||||
export async function openEditProjectModal(
|
||||
user: DashboardUser,
|
||||
project: ProjectItem,
|
||||
): Promise<void> {
|
||||
const isUserOnly = user.role === "USER";
|
||||
// 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정).
|
||||
const [members, assets] = await Promise.all([
|
||||
fetchCompanyMembers(project.company_id),
|
||||
fetchCompanyAssets(project.company_id),
|
||||
]);
|
||||
|
||||
const name = createInputField({
|
||||
label: L("B01_Dashboard_Table_Project"),
|
||||
@@ -111,6 +122,34 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
|
||||
type: "date",
|
||||
value: (project.design_date ?? "").slice(0, 10),
|
||||
});
|
||||
const personOptions = [
|
||||
{ value: "", text: "(미지정)" },
|
||||
...members.map((member) => ({
|
||||
value: String(member.id),
|
||||
text: member.position ? `${member.name} (${member.position})` : member.name,
|
||||
})),
|
||||
];
|
||||
const person = (label: string, current: number | null | undefined) =>
|
||||
createSelectField({ label, options: personOptions, value: String(current ?? "") });
|
||||
const pm = person("과업책임자 (도면 표제란)", project.pm_user_id);
|
||||
const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id);
|
||||
const designer = person("설계자 (도면 표제란)", project.designer_user_id);
|
||||
const logo = createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
assets,
|
||||
project.logo_asset_id,
|
||||
project.company_id,
|
||||
user,
|
||||
);
|
||||
const signature = createAssetField(
|
||||
"설계자 서명 (도면 표제란)",
|
||||
"SIGNATURE",
|
||||
assets,
|
||||
project.signature_asset_id,
|
||||
project.company_id,
|
||||
user,
|
||||
);
|
||||
|
||||
if (isUserOnly) {
|
||||
name.input.disabled = true;
|
||||
@@ -123,39 +162,54 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
|
||||
projectNumber.input.disabled = true;
|
||||
workAmount.input.disabled = true;
|
||||
designDate.input.disabled = true;
|
||||
pm.select.disabled = true;
|
||||
fieldLead.select.disabled = true;
|
||||
designer.select.disabled = true;
|
||||
}
|
||||
|
||||
openModal(
|
||||
L("B01_Dashboard_EditProject"),
|
||||
[
|
||||
name.root,
|
||||
region.root,
|
||||
roadType.root,
|
||||
year.root,
|
||||
length.root,
|
||||
memo.root,
|
||||
clientOrg.root,
|
||||
projectNumber.root,
|
||||
workAmount.root,
|
||||
designDate.root,
|
||||
],
|
||||
async () => {
|
||||
await updateProject(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
region: region.input.value.trim() || null,
|
||||
road_type: roadType.input.value.trim() || null,
|
||||
project_year: year.input.value ? Number(year.input.value) : null,
|
||||
estimated_length_m: length.input.value ? Number(length.input.value) : null,
|
||||
memo: memo.input.value.trim() || null,
|
||||
status: project.status,
|
||||
client_org: clientOrg.input.value.trim() || null,
|
||||
project_number: projectNumber.input.value.trim() || null,
|
||||
work_amount: workAmount.input.value.trim() || null,
|
||||
design_date: designDate.input.value || null,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
},
|
||||
// 칸이 많아 두 줄 격자로 — 첫 칸(공사명)만 가로로 다 쓴다.
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__form-grid";
|
||||
grid.append(
|
||||
name.root,
|
||||
region.root,
|
||||
roadType.root,
|
||||
year.root,
|
||||
length.root,
|
||||
memo.root,
|
||||
clientOrg.root,
|
||||
projectNumber.root,
|
||||
workAmount.root,
|
||||
designDate.root,
|
||||
pm.root,
|
||||
fieldLead.root,
|
||||
designer.root,
|
||||
logo.root,
|
||||
signature.root,
|
||||
);
|
||||
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
|
||||
|
||||
openModal(L("B01_Dashboard_EditProject"), [grid], async () => {
|
||||
await updateProject(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
region: region.input.value.trim() || null,
|
||||
road_type: roadType.input.value.trim() || null,
|
||||
project_year: year.input.value ? Number(year.input.value) : null,
|
||||
estimated_length_m: length.input.value ? Number(length.input.value) : null,
|
||||
memo: memo.input.value.trim() || null,
|
||||
status: project.status,
|
||||
client_org: clientOrg.input.value.trim() || null,
|
||||
project_number: projectNumber.input.value.trim() || null,
|
||||
work_amount: workAmount.input.value.trim() || null,
|
||||
design_date: designDate.input.value || null,
|
||||
pm_user_id: userId(pm.select),
|
||||
field_lead_user_id: userId(fieldLead.select),
|
||||
designer_user_id: userId(designer.select),
|
||||
logo_asset_id: logo.value(),
|
||||
signature_asset_id: signature.value(),
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
});
|
||||
}
|
||||
|
||||
export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void {
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
|
||||
.b01-dashboard__modal-panel {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2 * var(--spacing-24));
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -181,6 +183,70 @@
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__modal-subtitle {
|
||||
margin: var(--spacing-24) 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 로고·서명 칸 — 프로젝트 수정 모달 안 미리보기 줄과 고르기 모달의 격자 */
|
||||
.b01-dashboard__asset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-preview {
|
||||
max-height: 36px;
|
||||
max-width: 96px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-name {
|
||||
flex: 1;
|
||||
font-size: var(--text-body-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-dashboard__asset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset.is-selected,
|
||||
.b01-dashboard__asset:focus-visible {
|
||||
border-color: var(--color-royal-amethyst);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset img {
|
||||
max-height: 64px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.b01-dashboard__check {
|
||||
display: block;
|
||||
margin: var(--spacing-8) 0 var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b01-dashboard__header,
|
||||
.b01-dashboard__grid {
|
||||
|
||||
@@ -120,6 +120,9 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
||||
|
||||
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
|
||||
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
|
||||
로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다 —
|
||||
안 골랐거나 자산이 지워졌으면 그림째 빠진다. 011 의 `companies.logo_path` ·
|
||||
`users.signature_path` 는 더 읽지 않는다.
|
||||
|
||||
아직 못 채우는 자리와 이유:
|
||||
- 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
|
||||
@@ -132,9 +135,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount,
|
||||
p.design_date, c.name, c.logo_path,
|
||||
COALESCE(designer.name, owner.name),
|
||||
COALESCE(designer.signature_path, owner.signature_path),
|
||||
p.design_date, c.name, logo.file_path,
|
||||
COALESCE(designer.name, owner.name), sig.file_path,
|
||||
pm.name, lead.name
|
||||
FROM projects p
|
||||
LEFT JOIN companies c ON c.id = p.company_id
|
||||
@@ -142,6 +144,10 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
||||
LEFT JOIN users designer ON designer.id = p.designer_user_id
|
||||
LEFT JOIN users pm ON pm.id = p.pm_user_id
|
||||
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
|
||||
LEFT JOIN company_assets logo
|
||||
ON logo.id = p.logo_asset_id AND logo.deleted_at IS NULL
|
||||
LEFT JOIN company_assets sig
|
||||
ON sig.id = p.signature_asset_id AND sig.deleted_at IS NULL
|
||||
WHERE p.id = %s AND p.deleted_at IS NULL
|
||||
""",
|
||||
(str(project_id),),
|
||||
|
||||
Reference in New Issue
Block a user