- project_members 표 신설(016 마이그레이션, 기존 프로젝트는 생성자 자동 채움) - 프로젝트 수정 모달에 참여자 선택 추가, 참여자로 지정된 일반사용자는 해당 프로젝트 수정 가능 (백엔드 _can_edit_project 동반 수정) - B02 프로젝트 생성 시 생성자를 참여자로 자동 등록, 참여자도 같은 회사 구성원인지 검증 - 가입 요청 목록을 대기 상태만 표시 (처리 완료 건이 사용자 관리 목록과 중복 노출되던 문제) - 팀원 등록 시 남아 있던 가입 신청 자동 정리 - 서명·로고 선택 문구를 뜻이 드러나게 수정 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
180 lines
7.6 KiB
Python
180 lines
7.6 KiB
Python
"""회사 구성원과 회사 대표 로고 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한).
|
|
|
|
구성원은 도면 표제란의 사람 자리(과업책임자·분야별책임자·설계자)를 채우는 원천이라
|
|
자산·로고와 같은 결로 묶어 둔다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import aiomysql
|
|
from fastapi import HTTPException
|
|
|
|
from config.config_db import get_db_pool
|
|
|
|
from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company
|
|
from .B01_Dashboard_Repository_Assets import list_company_assets
|
|
|
|
|
|
async def list_company_members(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 id, email, name, position, department, role, is_master, status
|
|
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
|
(company_id,),
|
|
)
|
|
rows = list(await cursor.fetchall())
|
|
for row in rows:
|
|
row["role"] = _role(row.get("role"))
|
|
row["is_master"] = bool(row.get("is_master"))
|
|
return rows
|
|
|
|
|
|
async def list_unassigned_users(query: str) -> list[dict[str, Any]]:
|
|
"""소속이 없는 가입자만 찾는다 (2026-09-06 사용자 확정).
|
|
|
|
다른 회사 소속자는 보이지 않는다 — 팀원 등록은 「이미 가입한 사람을 고르는」 일이다.
|
|
"""
|
|
like = f"%{query.strip()}%"
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""SELECT id, name, position, email, department, status
|
|
FROM users
|
|
WHERE company_id IS NULL AND deleted_at IS NULL
|
|
AND status IN ('NO_COMPANY', 'PENDING')
|
|
AND (name LIKE %s OR email LIKE %s)
|
|
ORDER BY name, email LIMIT 20""",
|
|
(like, like),
|
|
)
|
|
return list(await cursor.fetchall())
|
|
|
|
|
|
async def attach_company_member(company_id: int, user_id: int) -> dict[str, Any] | None:
|
|
"""가입한 사람을 회사에 붙인다. 소속이 이미 있으면 붙이지 않는다."""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await connection.begin()
|
|
await cursor.execute(
|
|
"""SELECT id, company_id FROM users
|
|
WHERE id = %s AND deleted_at IS NULL FOR UPDATE""",
|
|
(user_id,),
|
|
)
|
|
user = await cursor.fetchone()
|
|
if not user or user["company_id"] is not None:
|
|
await connection.rollback()
|
|
return None
|
|
await cursor.execute(
|
|
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s,
|
|
is_master = FALSE
|
|
WHERE id = %s""",
|
|
(company_id, await role_for_company(company_id), user_id),
|
|
)
|
|
# 남아 있던 가입 신청은 닫는다 — 목록에 같은 사람이 두 번 보이지 않게 한다.
|
|
await cursor.execute(
|
|
"""UPDATE join_requests
|
|
SET status = CASE WHEN company_id = %s THEN 'APPROVED' ELSE 'REJECTED' END,
|
|
reviewed_at = CURRENT_TIMESTAMP
|
|
WHERE user_id = %s AND status = 'PENDING'""",
|
|
(company_id, user_id),
|
|
)
|
|
await connection.commit()
|
|
return await get_dashboard_me(user_id)
|
|
|
|
|
|
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
|
is_master = FALSE
|
|
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
|
(user_id, company_id),
|
|
)
|
|
changed = cursor.rowcount > 0
|
|
await connection.commit()
|
|
return changed
|
|
|
|
|
|
async def set_company_logo(company_id: int, asset_id: int | None) -> bool:
|
|
"""회사 대표 로고를 지정한다 (2026-09-02 사용자 확정 — 회사 등록 단계에서 받고 변경).
|
|
|
|
`asset_id` 가 같은 회사의 `kind='LOGO'` 자산인지는 라우터가 확인한다.
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"UPDATE companies SET logo_asset_id = %s WHERE id = %s AND deleted_at IS NULL",
|
|
(asset_id, company_id),
|
|
)
|
|
changed = cursor.rowcount > 0
|
|
await connection.commit()
|
|
return changed
|
|
|
|
|
|
async def check_project_refs(company_id: int, data: dict[str, Any]) -> None:
|
|
"""담당자·로고·서명은 그 회사의 것만 물린다 (2026-09-02 사용자 확정).
|
|
|
|
프로젝트 수정(B01)과 등록(B02)이 같은 규칙을 써야 해서 저장소에 둔다.
|
|
"""
|
|
user_ids = {
|
|
data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k)
|
|
}
|
|
# 참여자도 같은 회사 사람이어야 한다 (2026-09-06 사용자 확정).
|
|
user_ids |= {int(uid) for uid in (data.get("member_user_ids") or [])}
|
|
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.get(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="로고·서명은 같은 회사 자산이어야 합니다.")
|
|
|
|
|
|
async def list_project_member_ids(cursor: Any, project_ids: list[str]) -> dict[str, list[int]]:
|
|
"""프로젝트별 참여자 id 목록 (2026-09-06 사용자 확정)."""
|
|
if not project_ids:
|
|
return {}
|
|
marks = ", ".join(["%s"] * len(project_ids))
|
|
await cursor.execute(
|
|
f"""SELECT project_id, user_id FROM project_members
|
|
WHERE project_id IN ({marks}) ORDER BY user_id""",
|
|
tuple(project_ids),
|
|
)
|
|
result: dict[str, list[int]] = {}
|
|
for row in await cursor.fetchall():
|
|
key = row["project_id"] if isinstance(row, dict) else row[0]
|
|
value = row["user_id"] if isinstance(row, dict) else row[1]
|
|
result.setdefault(str(key), []).append(int(value))
|
|
return result
|
|
|
|
|
|
async def set_project_members(project_id: str, user_ids: list[int]) -> None:
|
|
"""참여자 목록을 통째로 맞춘다. 만든 사람은 화면에서 늘 포함해 보낸다."""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await connection.begin()
|
|
await cursor.execute("DELETE FROM project_members WHERE project_id = %s", (project_id,))
|
|
for user_id in dict.fromkeys(user_ids):
|
|
await cursor.execute(
|
|
"INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)",
|
|
(project_id, int(user_id)),
|
|
)
|
|
await connection.commit()
|
|
|
|
|
|
async def is_project_member(project_id: str, user_id: int) -> bool:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT 1 FROM project_members WHERE project_id = %s AND user_id = %s",
|
|
(project_id, user_id),
|
|
)
|
|
return await cursor.fetchone() is not None
|