Kyle Pope 3d22568b9c Add user connections, notification centre, and people integration
Implements the full User Connections & Notification Centre feature:

Phase 1 - Database: migrations 039-043 adding umbral_name to users,
profile/social fields to settings, notifications table, connection
request/user_connection tables, and linked_user_id to people.

Phase 2 - Notifications: backend CRUD router + service + 90-day purge,
frontend NotificationsPage with All/Unread filter, bell icon in sidebar
with unread badge polling every 60s.

Phase 3 - Settings: profile fields (phone, mobile, address, company,
job_title), social card with accept_connections toggle and per-field
sharing defaults, umbral name display with CopyableField.

Phase 4 - Connections: timing-safe user search, send/accept/reject flow
with atomic status updates, bidirectional UserConnection + Person records,
in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting.

Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention),
Ghost icon for umbral contacts, Umbral filter pill, split Add Person button,
shared field indicators (synced labels + Lock icons), disabled form inputs
for synced fields on umbral contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:10:16 +08:00

181 lines
5.1 KiB
Python

"""
Admin API schemas — Pydantic v2.
All admin-facing request/response shapes live here to keep the admin router
clean and testable in isolation.
"""
import re
from datetime import date, datetime
from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.auth import _validate_username, _validate_password_strength, _validate_email_format, _validate_name_field
# ---------------------------------------------------------------------------
# User list / detail
# ---------------------------------------------------------------------------
class UserListItem(BaseModel):
id: int
username: str
umbral_name: str = ""
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
role: str
is_active: bool
last_login_at: Optional[datetime] = None
last_password_change_at: Optional[datetime] = None
totp_enabled: bool
mfa_enforce_pending: bool
created_at: datetime
active_sessions: int = 0
model_config = ConfigDict(from_attributes=True)
class UserListResponse(BaseModel):
users: list[UserListItem]
total: int
class UserDetailResponse(UserListItem):
preferred_name: Optional[str] = None
date_of_birth: Optional[date] = None
must_change_password: bool = False
locked_until: Optional[datetime] = None
# ---------------------------------------------------------------------------
# Mutating user requests
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
"""Admin-created user — allows role selection (unlike public RegisterRequest)."""
model_config = ConfigDict(extra="forbid")
username: str
password: str
role: Literal["admin", "standard", "public_event_manager"] = "standard"
email: Optional[str] = Field(None, max_length=254)
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
preferred_name: Optional[str] = Field(None, max_length=100)
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
@field_validator("email")
@classmethod
def validate_email(cls, v: str | None) -> str | None:
return _validate_email_format(v)
@field_validator("first_name", "last_name", "preferred_name")
@classmethod
def validate_name_fields(cls, v: str | None) -> str | None:
return _validate_name_field(v)
class UpdateUserRoleRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
role: Literal["admin", "standard", "public_event_manager"]
class ToggleActiveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
is_active: bool
class ToggleMfaEnforceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enforce: bool
# ---------------------------------------------------------------------------
# System config
# ---------------------------------------------------------------------------
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
model_config = ConfigDict(from_attributes=True)
class SystemConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None
# ---------------------------------------------------------------------------
# Admin dashboard
# ---------------------------------------------------------------------------
class RecentLoginItem(BaseModel):
username: str
last_login_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class RecentAuditItem(BaseModel):
action: str
actor_username: Optional[str] = None
target_username: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AdminDashboardResponse(BaseModel):
total_users: int
active_users: int
admin_count: int
active_sessions: int
mfa_adoption_rate: float
recent_logins: list[RecentLoginItem]
recent_audit_entries: list[RecentAuditItem]
# ---------------------------------------------------------------------------
# Password reset
# ---------------------------------------------------------------------------
class ResetPasswordResponse(BaseModel):
message: str
temporary_password: str
class DeleteUserResponse(BaseModel):
message: str
deleted_username: str
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
class AuditLogEntry(BaseModel):
id: int
actor_username: Optional[str] = None
target_username: Optional[str] = None
action: str
detail: Optional[str] = None
ip_address: Optional[str] = None
created_at: datetime
class AuditLogResponse(BaseModel):
entries: list[AuditLogEntry]
total: int