diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index c0078efb..b6639b87 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -10,8 +10,10 @@ from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import startup as startup_crud from app.crud import study as study_crud +from app.crud import user as user_crud from app.schemas.site import SiteCreate, SiteRead, SiteUpdate from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate +from app.services.site_contact_display import build_contact_display, parse_contact_user_ids router = APIRouter() @@ -100,7 +102,17 @@ async def list_sites( include_inactive=include_inactive, site_ids=site_ids, ) - return list(sites) + contact_user_ids: set[uuid.UUID] = set() + for site in sites: + contact_user_ids.update(parse_contact_user_ids(site.contact)) + users_map = await user_crud.get_users_by_ids(db, contact_user_ids) + + result: list[SiteRead] = [] + for site in sites: + item = SiteRead.model_validate(site) + item.contact_display = build_contact_display(site.contact, users_map) + result.append(item) + return result @router.patch( diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py index fc9b73db..4b74d1bc 100644 --- a/backend/app/schemas/site.py +++ b/backend/app/schemas/site.py @@ -37,6 +37,7 @@ class SiteRead(BaseModel): pi_name: Optional[str] phone: Optional[str] contact: Optional[str] + contact_display: Optional[str] = None is_active: bool enrollment_target: Optional[int] enrollment_plan_start_date: Optional[date] diff --git a/backend/app/services/site_contact_display.py b/backend/app/services/site_contact_display.py new file mode 100644 index 00000000..820e2381 --- /dev/null +++ b/backend/app/services/site_contact_display.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import uuid +from typing import Mapping + + +def parse_contact_user_ids(contact: str | None) -> set[uuid.UUID]: + ids: set[uuid.UUID] = set() + for token in _contact_tokens(contact): + try: + ids.add(uuid.UUID(token)) + except ValueError: + continue + return ids + + +def build_contact_display(contact: str | None, users: Mapping[uuid.UUID, object]) -> str | None: + labels: list[str] = [] + for token in _contact_tokens(contact): + try: + user_id = uuid.UUID(token) + except ValueError: + labels.append(token) + continue + + user = users.get(user_id) + label = _user_display_name(user) + if label: + labels.append(label) + + return "、".join(labels) if labels else None + + +def _contact_tokens(contact: str | None) -> list[str]: + return [part.strip() for part in str(contact or "").split(",") if part.strip()] + + +def _user_display_name(user: object | None) -> str | None: + if user is None: + return None + for attr in ("full_name", "username", "email"): + value = getattr(user, attr, None) + if value: + return str(value) + return None diff --git a/backend/tests/test_site_contact_display.py b/backend/tests/test_site_contact_display.py new file mode 100644 index 00000000..6ec7277e --- /dev/null +++ b/backend/tests/test_site_contact_display.py @@ -0,0 +1,40 @@ +import uuid + +import pytest + +from app.models.user import User +from app.services.site_contact_display import build_contact_display + + +def make_user(user_id: uuid.UUID, full_name: str, email: str) -> User: + return User( + id=user_id, + email=email, + password_hash="hash", + full_name=full_name, + clinical_department="PMO", + ) + + +def test_build_contact_display_resolves_comma_separated_user_ids(): + first_id = uuid.uuid4() + second_id = uuid.uuid4() + users = { + first_id: make_user(first_id, "张三", "zhangsan@example.com"), + second_id: make_user(second_id, "李四", "lisi@example.com"), + } + + display = build_contact_display(f"{first_id}, {second_id}", users) + + assert display == "张三、李四" + + +@pytest.mark.parametrize("role", ["PM", "CRA", "PV", "QA", "CTA"]) +def test_build_contact_display_is_role_independent_for_site_read_roles(role: str): + user_id = uuid.uuid4() + users = {user_id: make_user(user_id, "周成", "zhoucheng@example.com")} + + display = build_contact_display(str(user_id), users) + + assert role + assert display == "周成" diff --git a/frontend/src/views/admin/Sites.vue b/frontend/src/views/admin/Sites.vue index d79004fa..4ef912f6 100644 --- a/frontend/src/views/admin/Sites.vue +++ b/frontend/src/views/admin/Sites.vue @@ -153,6 +153,7 @@ const memberNameMap = computed(() => { }); const contactLabel = (row: any) => { + if (row?.contact_display) return row.contact_display; if (!row?.contact) return TEXT.common.fallback; return String(row.contact) .split(",") diff --git a/frontend/src/views/ia/StartupMeetingAuth.test.ts b/frontend/src/views/ia/StartupMeetingAuth.test.ts index 49899698..1f561541 100644 --- a/frontend/src/views/ia/StartupMeetingAuth.test.ts +++ b/frontend/src/views/ia/StartupMeetingAuth.test.ts @@ -19,4 +19,20 @@ describe("StartupMeetingAuth permissions", () => { expect(source).toContain("canCreateAuth"); expect(source).toContain("if (!canCreateAuth.value)"); }); + + it("uses backend contact display names before falling back to member lookup", () => { + const source = readSource(); + + expect(source).toContain("row.contact_display"); + expect(source.indexOf("row.contact_display")).toBeLessThan(source.indexOf("memberNameMap.value[c] || c")); + }); + + it("loads site contact display names independently from project member permissions", () => { + const source = readSource(); + + expect(source).toContain("fetchSites(studyId, { limit: 500 })"); + expect(source).toContain("if (canReadMembers.value)"); + expect(source.indexOf("fetchSites(studyId, { limit: 500 })")).toBeLessThan(source.indexOf("if (canReadMembers.value)")); + expect(source.indexOf("contact_display: site.contact_display")).toBeLessThan(source.indexOf("const membersResp = canReadMembers.value")); + }); }); diff --git a/frontend/src/views/ia/StartupMeetingAuth.vue b/frontend/src/views/ia/StartupMeetingAuth.vue index ff811494..0658cf93 100644 --- a/frontend/src/views/ia/StartupMeetingAuth.vue +++ b/frontend/src/views/ia/StartupMeetingAuth.vue @@ -85,6 +85,7 @@ const siteActiveMap = computed(() => { }); const contactLabel = (row: any) => { + if (row?.contact_display) return row.contact_display; if (!row?.contact) return TEXT.common.fallback; return String(row.contact) .split(",") @@ -106,6 +107,7 @@ const kickoffRows = computed(() => { site_id: site.id, site_name: site.name || TEXT.common.fallback, contact: site.contact, + contact_display: site.contact_display, kickoff_date: meeting?.kickoff_date || null, meeting_id: meeting?.id, status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",