"""Global ledger metadata and grants; reuse collaboration revisions and sessions.""" import io import uuid import zipfile from datetime import datetime, timezone from pathlib import Path import aiofiles from fastapi import HTTPException from sqlalchemy import or_, select, text from app.core.config import settings from app.core.deps import is_system_admin from app.models.collaboration import CollaborationFile, CollaborationMember, CollaborationRevision from app.models.user import User, UserStatus from app.schemas.ledger import LedgerRead, LedgerSettings from app.services import collaboration_service as collaboration from app.services import ledger_access LEDGER_TEMPLATES = ( ("ledger-1", "医学事务部合同台账明细表.xlsx", "contract-ledger.xlsx", "记录合同编号、项目代码与合同内容"), ("ledger-2", "医学事务部临床运营项目编号.xlsx", "clinical-project-register.xlsx", "记录项目编号、产品信息与生效日期"), ) TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "ledgers" async def get_ledger(db, ledger_id, user, *, manage=False, lock=False): stmt = select(CollaborationFile).where( CollaborationFile.id == ledger_id, CollaborationFile.scope == "LEDGER", ).execution_options(populate_existing=True) if lock: stmt = stmt.with_for_update() item = await db.scalar(stmt) if not item: raise HTTPException(404, "台账不存在或未获授权") await ledger_access.require_access(db, item, user, manage=manage) return item async def read_ledger(db, item, user): role = await ledger_access.role_for(db, item, user) await ledger_access.require_access(db, item, user) owner = await db.get(User, item.owner_id) revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None return LedgerRead( id=item.id, title=item.title, description=item.description, status=item.status, owner_id=item.owner_id, owner_name=owner.full_name if owner else "", generation=item.generation, updated_at=item.updated_at, current_revision_id=item.current_revision_id, current_revision_no=revision.revision_no if revision else None, current_revision_file_size=revision.file_size if revision else None, current_revision_mime_type=revision.mime_type if revision else None, current_revision_created_at=revision.created_at if revision else None, role=role, can_edit=await ledger_access.can_edit(db, item, user), can_manage=role == "MANAGER", can_export=await ledger_access.can_export(db, item, user), allow_export=item.allow_export, allow_sheet_structure_edit=item.allow_sheet_structure_edit, allow_edit_request=item.allow_edit_request, can_request_edit=await collaboration.can_request_edit_file(db, item, user), edit_request_status=await collaboration.edit_request_status(db, item, user.id), can_transfer_ownership=await collaboration.can_transfer_ownership(db, item, user), ) async def list_ledgers(db, user): stmt = select(CollaborationFile).where(CollaborationFile.scope == "LEDGER") if not is_system_admin(user): granted = select(CollaborationMember.file_id).where( CollaborationMember.user_id == user.id, CollaborationMember.role.in_(["VIEWER", "EDITOR", "MANAGER"]), ) stmt = stmt.where(or_(CollaborationFile.owner_id == user.id, CollaborationFile.id.in_(granted))) rows = (await db.scalars(stmt.order_by(CollaborationFile.ledger_key))).all() return [await read_ledger(db, item, user) for item in rows] async def initialize_ledgers(db, user): if not is_system_admin(user): raise HTTPException(403, "仅系统管理员可以初始化台账") # Serialize initialization across administrators; unique keys also prevent duplicates. if db.get_bind().dialect.name == "postgresql": await db.execute(text("SELECT pg_advisory_xact_lock(2026090301)")) for key, title, template_name, description in LEDGER_TEMPLATES: if await db.scalar(select(CollaborationFile.id).where(CollaborationFile.ledger_key == key)): continue item = CollaborationFile( id=uuid.uuid4(), scope="LEDGER", ledger_key=key, study_id=None, folder_id=None, title=title, description=description, file_type="cell", extension="xlsx", owner_id=user.id, status="ACTIVE", generation=1, allow_sheet_structure_edit=False, allow_export=False, ) db.add(item) await db.flush() content, backup = collaboration.apply_workbook_structure_policy( (TEMPLATE_ROOT / template_name).read_bytes(), file_id=item.id, allow_sheet_structure_edit=False, protection_backup=None, ) item.sheet_structure_protection_backup = backup await collaboration.append_revision(db, item, content, source="CREATE", created_by=user.id) await db.commit() return await list_ledgers(db, user) def check_generation(item, generation): if item.generation != generation: raise HTTPException(409, "台账已发生变化,请刷新后重试") def advance_generation(item): item.generation += 1 item.updated_at = datetime.now(timezone.utc) async def active_user(db, user_id): user = await db.get(User, user_id) if not user or not user.is_active: raise HTTPException(422, "请选择有效的系统账号") return user async def update_settings(db, item, payload, user): await ledger_access.require_access(db, item, user, manage=True) check_generation(item, payload.generation) if payload.owner_id != item.owner_id: if not is_system_admin(user): raise HTTPException(403, "仅系统管理员可以变更台账负责人") await active_user(db, payload.owner_id) if payload.allow_sheet_structure_edit != item.allow_sheet_structure_edit: revision = await db.get(CollaborationRevision, item.current_revision_id) if not revision or not Path(revision.file_uri).is_file(): raise HTTPException(404, "台账内容不存在") async with aiofiles.open(revision.file_uri, "rb") as stream: content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1) content, backup = collaboration.apply_workbook_structure_policy( content, file_id=item.id, allow_sheet_structure_edit=payload.allow_sheet_structure_edit, protection_backup=item.sheet_structure_protection_backup, ) item.sheet_structure_protection_backup = backup await collaboration.append_revision(db, item, content, source="PERMISSION_CHANGE", created_by=user.id) item.title = collaboration._safe_title(payload.title, "xlsx") item.description = payload.description.strip() item.status = payload.status item.owner_id = payload.owner_id item.allow_export = payload.allow_export if payload.allow_edit_request is not None: item.allow_edit_request = payload.allow_edit_request item.allow_sheet_structure_edit = payload.allow_sheet_structure_edit advance_generation(item) await db.commit() return await read_ledger(db, item, user) async def update_access_settings(db, item, payload, user): values = dict(title=item.title, description=item.description, status=item.status, owner_id=item.owner_id, allow_export=item.allow_export, allow_edit_request=item.allow_edit_request, allow_sheet_structure_edit=item.allow_sheet_structure_edit) values.update(payload.model_dump(exclude_none=True)) return await update_settings(db, item, LedgerSettings(**values), user) async def list_members(db, item): rows = (await db.execute(select(CollaborationMember, User).join( User, User.id == CollaborationMember.user_id, ).where(CollaborationMember.file_id == item.id).order_by(User.full_name))).all() grants = {user.id: dict(user_id=user.id, full_name=user.full_name, email=user.email, role=member.role, is_active=user.is_active) for member, user in rows} implicit = (await db.scalars(select(User).where(or_( User.id == item.owner_id, (User.is_admin.is_(True)) & (User.status == UserStatus.ACTIVE), )).order_by(User.full_name))).all() for user in implicit: grants[user.id] = dict(user_id=user.id, full_name=user.full_name, email=user.email, role="MANAGER", is_active=user.is_active, protected_label="所有者" if user.id == item.owner_id else "系统管理员") return sorted(grants.values(), key=lambda member: ( member["user_id"] != item.owner_id, member["role"] != "MANAGER", member["full_name"], )) async def set_member(db, item, payload, user): await ledger_access.require_access(db, item, user, manage=True) target = await active_user(db, payload.user_id) if target.id == item.owner_id or is_system_admin(target): raise HTTPException(422, "负责人和系统管理员已拥有管理权限") member = await db.scalar(select(CollaborationMember).where( CollaborationMember.file_id == item.id, CollaborationMember.user_id == target.id, )) if member and member.role == payload.role: return if member: member.role = payload.role else: db.add(CollaborationMember(file_id=item.id, user_id=target.id, role=payload.role, invited_by=user.id)) advance_generation(item) await db.commit() async def remove_member(db, item, user_id, user): await ledger_access.require_access(db, item, user, manage=True) if user_id == item.owner_id: raise HTTPException(422, "不能移除台账负责人") target = await db.get(User, user_id) if target and is_system_admin(target): raise HTTPException(422, "系统管理员始终拥有管理权限") member = await db.scalar(select(CollaborationMember).where( CollaborationMember.file_id == item.id, CollaborationMember.user_id == user_id, )) if member: await db.delete(member) advance_generation(item) await db.commit() async def candidates(db, keyword): stmt = select(User).where(User.status == UserStatus.ACTIVE) if keyword: pattern = f"%{keyword}%" stmt = stmt.where(or_(User.full_name.ilike(pattern), User.email.ilike(pattern))) rows = (await db.scalars(stmt.order_by(User.full_name, User.id))).all() return [dict(user_id=user.id, full_name=user.full_name, email=user.email, role_in_study="系统管理员" if user.is_admin else "系统账号") for user in rows] async def import_content(db, item, upload, generation, user): await ledger_access.require_access(db, item, user, manage=True) check_generation(item, generation) if item.status != "ACTIVE": raise HTTPException(409, "请先启用台账再导入") if Path(upload.filename or "").suffix.lower() != ".xlsx": raise HTTPException(415, "台账仅支持 XLSX 文件") content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1) if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES: raise HTTPException(413, "台账文件为空或超出大小限制") try: with zipfile.ZipFile(io.BytesIO(content)) as package: if "xl/workbook.xml" not in package.namelist(): raise ValueError() if sum(info.file_size for info in package.infolist()) > settings.COLLABORATION_MAX_FILE_BYTES * 20: raise ValueError() if any("vbaproject" in name.lower() for name in package.namelist()): raise ValueError() except (zipfile.BadZipFile, ValueError): raise HTTPException(422, "XLSX 文件无效、包含宏或解压后过大") content, backup = collaboration.apply_workbook_structure_policy( content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit, protection_backup=None, ) item.sheet_structure_protection_backup = backup await collaboration.append_revision(db, item, content, source="IMPORT", created_by=user.id) advance_generation(item) await db.commit() return await read_ledger(db, item, user) async def restore_revision(db, item, revision_id, generation, user): await ledger_access.require_access(db, item, user, manage=True) check_generation(item, generation) if item.status != "ACTIVE": raise HTTPException(409, "请先启用台账再恢复版本") return await collaboration.restore_revision(db, item, revision_id, user, None)