发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""add desktop phase 2 notification and client metadata state
|
||||
|
||||
Revision ID: 20260630_02
|
||||
Revises: 20260630_01
|
||||
Create Date: 2026-06-30 21:30:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260630_02"
|
||||
down_revision: Union[str, None] = "20260630_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||
op.create_index(
|
||||
"ix_security_log_client_created",
|
||||
"security_access_logs",
|
||||
["client_type", "client_version", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"desktop_notification_subscriptions",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"desktop_notification_deliveries",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"user_id",
|
||||
"distribution_id",
|
||||
name="uq_desktop_notification_user_distribution",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_desktop_notification_claim",
|
||||
"desktop_notification_deliveries",
|
||||
["user_id", "delivered_at", "claimed_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries")
|
||||
op.drop_table("desktop_notification_deliveries")
|
||||
op.drop_table("desktop_notification_subscriptions")
|
||||
op.drop_index("ix_security_log_client_created", table_name="security_access_logs")
|
||||
for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"):
|
||||
op.drop_column("security_access_logs", column)
|
||||
@@ -346,13 +346,10 @@ async def preview_attachment(
|
||||
|
||||
|
||||
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||
payload = decode_token(token)
|
||||
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -130,8 +130,10 @@ def ensure_user_active(db_user) -> None:
|
||||
|
||||
@router.get("/email-domains", response_model=EmailDomainsResponse)
|
||||
async def read_email_domains(
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> EmailDomainsResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
rows = await email_service.list_email_settings(db)
|
||||
return EmailDomainsResponse(items=[row.register_domain for row in rows])
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.schemas.notification import (
|
||||
DesktopNotificationAckRequest,
|
||||
DesktopNotificationClaimRequest,
|
||||
DesktopNotificationClaimResponse,
|
||||
DesktopNotificationSubscriptionRead,
|
||||
DesktopNotificationSubscriptionUpdate,
|
||||
)
|
||||
from app.services import desktop_notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||
async def read_subscription(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationSubscriptionRead:
|
||||
subscription = await desktop_notification_service.get_subscription(db, current_user.id)
|
||||
return DesktopNotificationSubscriptionRead(
|
||||
enabled=bool(subscription and subscription.enabled),
|
||||
enabled_at=subscription.enabled_at if subscription else None,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||
async def update_subscription(
|
||||
payload: DesktopNotificationSubscriptionUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationSubscriptionRead:
|
||||
subscription = await desktop_notification_service.set_subscription(
|
||||
db, current_user.id, payload.enabled
|
||||
)
|
||||
return DesktopNotificationSubscriptionRead(
|
||||
enabled=subscription.enabled,
|
||||
enabled_at=subscription.enabled_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/claim", response_model=DesktopNotificationClaimResponse)
|
||||
async def claim_notifications(
|
||||
payload: DesktopNotificationClaimRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationClaimResponse:
|
||||
if payload.limit < 1 or payload.limit > 50:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间")
|
||||
claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications(
|
||||
db, current_user.id, payload.limit
|
||||
)
|
||||
return DesktopNotificationClaimResponse(
|
||||
claim_token=claim_token,
|
||||
lease_expires_at=lease_expires_at,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def acknowledge_notifications(
|
||||
payload: DesktopNotificationAckRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.acknowledge_notifications(
|
||||
db,
|
||||
current_user.id,
|
||||
payload.claim_token,
|
||||
payload.delivered_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def mark_notification_read(
|
||||
distribution_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.mark_notification_read(
|
||||
db, current_user.id, distribution_id
|
||||
)
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids
|
||||
from app.core.permission_monitor import (
|
||||
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES,
|
||||
CACHE_METRICS_WINDOW_SECONDS,
|
||||
@@ -43,6 +43,11 @@ class MonitoringScope:
|
||||
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
|
||||
if is_system_admin(current_user):
|
||||
return MonitoringScope(is_admin=True, study_ids=set())
|
||||
user_id = getattr(current_user, "id", None)
|
||||
if user_id:
|
||||
study_ids = await list_active_pm_study_ids(db, user_id)
|
||||
if study_ids:
|
||||
return MonitoringScope(is_admin=False, study_ids=study_ids)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
|
||||
@@ -479,6 +484,8 @@ async def get_security_access_logs(
|
||||
_=Depends(get_current_user),
|
||||
status_min: Optional[int] = Query(None, ge=100, le=599),
|
||||
auth_status: Optional[str] = Query(None),
|
||||
client_type: Optional[str] = Query(None),
|
||||
client_version: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
@@ -486,11 +493,17 @@ async def get_security_access_logs(
|
||||
scope = await resolve_monitoring_scope(db, _)
|
||||
if not scope.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
client_type = client_type if isinstance(client_type, str) else None
|
||||
client_version = client_version if isinstance(client_version, str) else None
|
||||
conditions = []
|
||||
if status_min is not None:
|
||||
conditions.append(SecurityAccessLog.status_code >= status_min)
|
||||
if auth_status:
|
||||
conditions.append(SecurityAccessLog.auth_status == auth_status)
|
||||
if client_type:
|
||||
conditions.append(SecurityAccessLog.client_type == client_type)
|
||||
if client_version:
|
||||
conditions.append(SecurityAccessLog.client_version == client_version)
|
||||
|
||||
query = select(SecurityAccessLog)
|
||||
count_query = select(func.count()).select_from(SecurityAccessLog)
|
||||
@@ -545,6 +558,11 @@ async def get_security_access_logs(
|
||||
"ip_city": ip_location.city,
|
||||
"ip_isp": ip_location.isp,
|
||||
"user_agent": log.user_agent,
|
||||
"client_type": log.client_type,
|
||||
"client_version": log.client_version,
|
||||
"client_platform": log.client_platform,
|
||||
"build_channel": log.build_channel,
|
||||
"build_commit": log.build_commit,
|
||||
"auth_status": log.auth_status,
|
||||
"user_identifier": log.user_identifier,
|
||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -10,6 +10,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
|
||||
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
|
||||
api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"])
|
||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||
api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
||||
|
||||
@@ -26,6 +26,10 @@ class Settings(BaseSettings):
|
||||
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
||||
SETTINGS_ENCRYPTION_KEY: Optional[str] = None
|
||||
FRONTEND_PUBLIC_URL: str = "http://localhost:8888"
|
||||
CORS_ALLOWED_ORIGINS: str = (
|
||||
"http://localhost:8888,http://localhost:5173,"
|
||||
"tauri://localhost,http://tauri.localhost"
|
||||
)
|
||||
IP2REGION_XDB_PATH: Optional[str] = None
|
||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||
|
||||
@@ -36,3 +40,11 @@ def get_settings() -> Settings:
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def get_cors_allowed_origins() -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
@@ -42,4 +42,8 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
||||
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
|
||||
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
||||
from app.models.desktop_notification import ( # noqa: F401
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
|
||||
|
||||
+17
-3
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.config import get_cors_allowed_origins, settings
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.core.login_crypto import validate_login_crypto_configuration
|
||||
from app.crud.user import ensure_admin_exists
|
||||
@@ -116,10 +116,19 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=get_cors_allowed_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_headers=[
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CTMS-Client-Type",
|
||||
"X-CTMS-Client-Version",
|
||||
"X-CTMS-Client-Platform",
|
||||
"X-CTMS-Build-Channel",
|
||||
"X-CTMS-Build-Commit",
|
||||
],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -269,6 +278,11 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"client_type": request.headers.get("x-ctms-client-type"),
|
||||
"client_version": request.headers.get("x-ctms-client-version"),
|
||||
"client_platform": request.headers.get("x-ctms-client-platform"),
|
||||
"build_channel": request.headers.get("x-ctms-build-channel"),
|
||||
"build_commit": request.headers.get("x-ctms-build-commit"),
|
||||
"auth_status": auth_status,
|
||||
"user_identifier": user_identifier,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class DesktopNotificationSubscription(Base):
|
||||
__tablename__ = "desktop_notification_subscriptions"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class DesktopNotificationDelivery(Base):
|
||||
__tablename__ = "desktop_notification_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"),
|
||||
Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
distribution_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -30,6 +30,11 @@ class SecurityAccessLog(Base):
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -16,5 +16,33 @@ class NotificationItem(BaseModel):
|
||||
change_summary: str | None = None
|
||||
effective_at: datetime | None = None
|
||||
created_at: datetime
|
||||
study_id: uuid.UUID | None = None
|
||||
study_name: str | None = None
|
||||
delivered_at: datetime | None = None
|
||||
read_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DesktopNotificationSubscriptionRead(BaseModel):
|
||||
enabled: bool
|
||||
enabled_at: datetime | None = None
|
||||
|
||||
|
||||
class DesktopNotificationSubscriptionUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DesktopNotificationClaimRequest(BaseModel):
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class DesktopNotificationClaimResponse(BaseModel):
|
||||
claim_token: uuid.UUID | None = None
|
||||
lease_expires_at: datetime | None = None
|
||||
items: list[NotificationItem]
|
||||
|
||||
|
||||
class DesktopNotificationAckRequest(BaseModel):
|
||||
claim_token: uuid.UUID
|
||||
delivered_ids: list[uuid.UUID]
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, exists, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.desktop_notification import (
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.study import Study
|
||||
from app.models.study_member import StudyMember
|
||||
from app.schemas.notification import NotificationItem
|
||||
|
||||
CLAIM_LEASE = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None:
|
||||
return await db.get(DesktopNotificationSubscription, user_id)
|
||||
|
||||
|
||||
async def set_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
enabled: bool,
|
||||
) -> DesktopNotificationSubscription:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription is None:
|
||||
subscription = DesktopNotificationSubscription(
|
||||
user_id=user_id,
|
||||
enabled=enabled,
|
||||
enabled_at=now if enabled else None,
|
||||
)
|
||||
db.add(subscription)
|
||||
elif subscription.enabled != enabled:
|
||||
subscription.enabled = enabled
|
||||
subscription.enabled_at = now if enabled else None
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
return subscription
|
||||
|
||||
|
||||
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
|
||||
role_target_exists = exists(
|
||||
select(StudyMember.id).where(
|
||||
StudyMember.study_id == Document.trial_id,
|
||||
StudyMember.user_id == user_id,
|
||||
StudyMember.is_active.is_(True),
|
||||
StudyMember.role_in_study == Distribution.target_id,
|
||||
)
|
||||
)
|
||||
target_matches = or_(
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.USER,
|
||||
Distribution.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.ROLE,
|
||||
role_target_exists,
|
||||
),
|
||||
)
|
||||
return (
|
||||
select(
|
||||
Distribution,
|
||||
Document,
|
||||
DocumentVersion,
|
||||
Study,
|
||||
DesktopNotificationDelivery,
|
||||
)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
Distribution.created_at >= enabled_at,
|
||||
target_matches,
|
||||
or_(
|
||||
DesktopNotificationDelivery.id.is_(None),
|
||||
and_(
|
||||
DesktopNotificationDelivery.delivered_at.is_(None),
|
||||
or_(
|
||||
DesktopNotificationDelivery.claimed_at.is_(None),
|
||||
DesktopNotificationDelivery.claimed_at < lease_cutoff,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(Distribution.created_at.asc())
|
||||
.with_for_update(of=Distribution, skip_locked=True)
|
||||
)
|
||||
|
||||
|
||||
async def claim_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
if not subscription or not subscription.enabled or not subscription.enabled_at:
|
||||
return None, None, []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = uuid.uuid4()
|
||||
rows = (
|
||||
await db.execute(
|
||||
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50)))
|
||||
)
|
||||
).all()
|
||||
items: list[NotificationItem] = []
|
||||
for distribution, document, version, study, delivery in rows:
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution.id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.claim_token = token
|
||||
delivery.claimed_at = now
|
||||
items.append(
|
||||
NotificationItem(
|
||||
id=distribution.id,
|
||||
document_id=document.id,
|
||||
version_id=version.id,
|
||||
document_title=document.title,
|
||||
document_no=document.doc_no,
|
||||
version_no=version.version_no,
|
||||
change_summary=version.change_summary,
|
||||
effective_at=version.effective_at,
|
||||
created_at=distribution.created_at,
|
||||
study_id=study.id,
|
||||
study_name=study.name,
|
||||
delivered_at=delivery.delivered_at,
|
||||
read_at=delivery.read_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
if not items:
|
||||
return None, None, []
|
||||
return token, now + CLAIM_LEASE, items
|
||||
|
||||
|
||||
async def acknowledge_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
claim_token: uuid.UUID,
|
||||
delivered_ids: list[uuid.UUID],
|
||||
) -> None:
|
||||
if delivered_ids:
|
||||
await db.execute(
|
||||
update(DesktopNotificationDelivery)
|
||||
.where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.claim_token == claim_token,
|
||||
DesktopNotificationDelivery.distribution_id.in_(delivered_ids),
|
||||
)
|
||||
.values(delivered_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_notification_read(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
distribution_id: uuid.UUID,
|
||||
) -> None:
|
||||
delivery = (
|
||||
await db.execute(
|
||||
select(DesktopNotificationDelivery).where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.distribution_id == distribution_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution_id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.read_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
@@ -10,7 +10,7 @@ from typing import Iterable
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope
|
||||
@@ -30,6 +30,8 @@ from app.models.audit_log import AuditLog
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
||||
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.study import Study
|
||||
from app.schemas.acknowledgement import AcknowledgementCreate
|
||||
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
||||
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
|
||||
@@ -778,9 +780,20 @@ async def list_distribution_notifications(
|
||||
DocumentVersion.version_no,
|
||||
DocumentVersion.change_summary,
|
||||
DocumentVersion.effective_at,
|
||||
Study.name.label("study_name"),
|
||||
DesktopNotificationDelivery.delivered_at,
|
||||
DesktopNotificationDelivery.read_at,
|
||||
)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.user_id == current_user.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Document.trial_id == study_id,
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
@@ -804,6 +817,10 @@ async def list_distribution_notifications(
|
||||
change_summary=row.change_summary,
|
||||
effective_at=row.effective_at,
|
||||
created_at=row.created_at,
|
||||
study_id=study_id,
|
||||
study_name=row.study_name,
|
||||
delivered_at=row.delivered_at,
|
||||
read_at=row.read_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
@@ -76,6 +76,11 @@ class SecurityAccessLogWriter:
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
client_ip=entry.get("client_ip"),
|
||||
user_agent=entry.get("user_agent"),
|
||||
client_type=entry.get("client_type"),
|
||||
client_version=entry.get("client_version"),
|
||||
client_platform=entry.get("client_platform"),
|
||||
build_channel=entry.get("build_channel"),
|
||||
build_commit=entry.get("build_commit"),
|
||||
auth_status=entry["auth_status"],
|
||||
user_identifier=entry.get("user_identifier"),
|
||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||
|
||||
@@ -70,6 +70,22 @@ async def add_email_settings(SessionLocal, domain: str) -> None:
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_domains_come_only_from_email_service_settings(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
|
||||
empty_response = await client.get("/api/v1/auth/email-domains")
|
||||
assert empty_response.status_code == 200
|
||||
assert empty_response.json() == {"items": []}
|
||||
assert empty_response.headers["cache-control"] == "no-store"
|
||||
|
||||
await add_email_settings(SessionLocal, "example.com")
|
||||
configured_response = await client.get("/api/v1/auth/email-domains")
|
||||
assert configured_response.status_code == 200
|
||||
assert configured_response.json() == {"items": ["example.com"]}
|
||||
assert configured_response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
|
||||
key_resp = await client.get("/api/v1/auth/login-key")
|
||||
assert key_resp.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user