优化中心联系人展示名称解析
- 新增中心联系人展示名解析服务,支持从逗号分隔用户 ID 解析姓名\n- 中心列表接口返回 contact_display,避免前端依赖项目成员权限才能展示联系人姓名\n- 中心管理和立项会议授权页面优先使用后端联系人展示名\n- 补充联系人解析服务测试和立项会议授权页面展示顺序测试
This commit is contained in:
@@ -10,8 +10,10 @@ from app.crud import audit as audit_crud
|
|||||||
from app.crud import site as site_crud
|
from app.crud import site as site_crud
|
||||||
from app.crud import startup as startup_crud
|
from app.crud import startup as startup_crud
|
||||||
from app.crud import study as study_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.site import SiteCreate, SiteRead, SiteUpdate
|
||||||
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
|
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
|
||||||
|
from app.services.site_contact_display import build_contact_display, parse_contact_user_ids
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -100,7 +102,17 @@ async def list_sites(
|
|||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
site_ids=site_ids,
|
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(
|
@router.patch(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class SiteRead(BaseModel):
|
|||||||
pi_name: Optional[str]
|
pi_name: Optional[str]
|
||||||
phone: Optional[str]
|
phone: Optional[str]
|
||||||
contact: Optional[str]
|
contact: Optional[str]
|
||||||
|
contact_display: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
enrollment_target: Optional[int]
|
enrollment_target: Optional[int]
|
||||||
enrollment_plan_start_date: Optional[date]
|
enrollment_plan_start_date: Optional[date]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 == "周成"
|
||||||
@@ -153,6 +153,7 @@ const memberNameMap = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contactLabel = (row: any) => {
|
const contactLabel = (row: any) => {
|
||||||
|
if (row?.contact_display) return row.contact_display;
|
||||||
if (!row?.contact) return TEXT.common.fallback;
|
if (!row?.contact) return TEXT.common.fallback;
|
||||||
return String(row.contact)
|
return String(row.contact)
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|||||||
@@ -19,4 +19,20 @@ describe("StartupMeetingAuth permissions", () => {
|
|||||||
expect(source).toContain("canCreateAuth");
|
expect(source).toContain("canCreateAuth");
|
||||||
expect(source).toContain("if (!canCreateAuth.value)");
|
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"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const siteActiveMap = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contactLabel = (row: any) => {
|
const contactLabel = (row: any) => {
|
||||||
|
if (row?.contact_display) return row.contact_display;
|
||||||
if (!row?.contact) return TEXT.common.fallback;
|
if (!row?.contact) return TEXT.common.fallback;
|
||||||
return String(row.contact)
|
return String(row.contact)
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -106,6 +107,7 @@ const kickoffRows = computed(() => {
|
|||||||
site_id: site.id,
|
site_id: site.id,
|
||||||
site_name: site.name || TEXT.common.fallback,
|
site_name: site.name || TEXT.common.fallback,
|
||||||
contact: site.contact,
|
contact: site.contact,
|
||||||
|
contact_display: site.contact_display,
|
||||||
kickoff_date: meeting?.kickoff_date || null,
|
kickoff_date: meeting?.kickoff_date || null,
|
||||||
meeting_id: meeting?.id,
|
meeting_id: meeting?.id,
|
||||||
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
|
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
|
||||||
|
|||||||
Reference in New Issue
Block a user