from __future__ import annotations from pathlib import Path from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.calendar import CAPABILITY_CALENDAR_SCHEDULING from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( DocumentationTopic, DocumentationCondition, FrontendModule, FrontendRoute, MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, NavItem, PermissionDefinition, PublicFrontendRoute, RoleTemplate, ) from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.people import ( CAPABILITY_ACCESS_PEOPLE_SEARCH, CAPABILITY_ADDRESSES_PEOPLE_SEARCH, ) from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING, PollCapabilityError from govoplan_core.core.poll_participation import ( CAPABILITY_POLL_PARTICIPATION_GATEWAY, PollResponseGatewayRef, poll_participation_gateway_provider, ) from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata MODULE_ID = "scheduling" MODULE_NAME = "Scheduling" MODULE_VERSION = "0.1.15" READ_SCOPE = "scheduling:schedule:read" WRITE_SCOPE = "scheduling:schedule:write" ADMIN_SCOPE = "scheduling:schedule:admin" RESPOND_SCOPE = "scheduling:availability:write" def _permission(scope: str, label: str, description: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( scope=scope, label=label, description=description, category="Scheduling", level="tenant", module_id=module_id, resource=resource, action=action, ) PERMISSIONS = ( _permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."), _permission(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."), _permission(ADMIN_SCOPE, "Administer scheduling", "Manage every tenant scheduling request and configure scheduling policies, external participation, and retention defaults."), _permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."), ) ROLE_TEMPLATES = ( RoleTemplate( slug="scheduling_manager", name="Scheduling manager", description="Create scheduling polls and manage candidate slots and outcomes for requests the account organizes.", permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE), ), RoleTemplate( slug="scheduling_participant", name="Scheduling participant", description="Read assigned scheduling polls and submit own availability.", permissions=(READ_SCOPE, RESPOND_SCOPE), ), ) DOCUMENTATION = ( DocumentationTopic( id="scheduling.module-boundary", title="Scheduling module boundary", summary="Meeting scheduling and Terminfindung built on reusable Poll availability primitives.", body=( "Scheduling owns meeting scheduling workflows, candidate slots, participant availability " "collection, conflict explanation, reminders, and decision handoff. It depends on Poll " "for reusable availability matrices and poll-backed workflow context, and can optionally " "trigger Evaluation for post-event feedback. " "Access is optional: when installed, Scheduling can use principal resolution, permission " "evaluation, groups, and role templates; without it, reduced signed-link or local organizer " "flows remain possible." ), layer="available", documentation_types=("admin", "user"), audience=("user", "operator", "module_admin", "product_owner"), related_modules=("poll", "evaluation", "calendar", "appointments", "mail", "notifications", "portal"), metadata={"seed": True}, ), DocumentationTopic( id="scheduling.find-and-decide-meeting-time", title="Find and decide a meeting time", summary="Create candidate slots, invite internal or external participants, compare availability, and turn the selected slot into a calendar event when Calendar is available.", body=( "Scheduling records participant requirements, quorum and weighting constraints, response deadlines, reminders, and yes/no/maybe availability through Poll. " "Calendar-aware organizers can inspect conflicts and create tentative holds before deciding. After a decision, Scheduling releases unused holds, creates or links the final event, and records notification handoff state. " "Signed external links expose only the bounded request information allowed by the request's participation and privacy policy. Their governed Poll invitation resolves the tenant before Scheduling runs, so tenant module policy can withdraw public participation without weakening token validation." ), layer="configured", documentation_types=("user",), audience=("user", "organizer", "participant"), conditions=( DocumentationCondition( any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, RESPOND_SCOPE), ), ), related_modules=("poll", "calendar", "notifications", "mail"), metadata={ "kind": "workflow", "route": "/scheduling", "screen": "Scheduling", "help_contexts": [ "scheduling.list", "scheduling.request", "scheduling.editor", "scheduling.public-participation", ], "steps": [ "Prepare candidate times and participation controls.", "Collect and review availability.", "Close the poll and confirm the selected time.", "Hand the decision to Calendar when configured.", ], "outcome": "A recorded scheduling decision with bounded participation and optional Calendar handoff.", "verification": "The request detail shows the decided slot, lifecycle state, participant aggregate, and any Calendar event reference.", }, ), DocumentationTopic( id="scheduling.calendar-coordination", title="Configure scheduling calendar coordination", summary="Understand the Calendar capability and permissions required for conflict checks, tentative holds, and final event handoff.", body=( "Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. " "A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. " "Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals." ), layer="configured", documentation_types=("admin", "user"), audience=("organizer", "module_admin", "tenant_admin"), related_modules=("calendar", "access", "policy"), metadata={ "kind": "reference", "help_contexts": ["scheduling.calendar-integration", "scheduling.calendar-coordination"], }, ), DocumentationTopic( id="scheduling.participation-governance", title="Govern public scheduling participation", summary="Resolve disabled guest invitations without weakening signed-link privacy or participation policy.", body=( "Public invitation links are issued only when the configured response, privacy, password, email, and update controls can be enforced by the public participation gateway. " "When enforcement is unavailable, signed-in participants may continue to respond through their assigned request, but the system does not issue a weaker guest link. " "A system or tenant administrator must restore the governed Poll/public-participation capability or keep the request limited to signed-in participation." ), layer="configured", documentation_types=("admin",), audience=("operator", "module_admin", "tenant_admin"), related_modules=("poll", "policy", "access"), metadata={ "kind": "pattern", "help_contexts": [ "scheduling.public-participation-blocker", "scheduling.public-participation", ], }, ), ) def _tenant_summary(session, tenant_id: str) -> dict[str, int]: from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest return { "scheduling_requests": ( session.query(SchedulingRequest) .filter(SchedulingRequest.tenant_id == tenant_id, SchedulingRequest.deleted_at.is_(None)) .count() ), "scheduling_candidate_slots": ( session.query(SchedulingCandidateSlot) .filter(SchedulingCandidateSlot.tenant_id == tenant_id, SchedulingCandidateSlot.deleted_at.is_(None)) .count() ), "scheduling_participants": ( session.query(SchedulingParticipant) .filter(SchedulingParticipant.tenant_id == tenant_id, SchedulingParticipant.deleted_at.is_(None)) .count() ), "scheduling_notifications": ( session.query(SchedulingNotification) .filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending") .count() ), } def _scheduling_router(context: ModuleContext): from govoplan_scheduling.backend.runtime import configure_runtime from govoplan_scheduling.backend.router import router configure_runtime(registry=context.registry, settings=context.settings) return router def _public_tenant_resolver(request: object, session: object) -> str | None: path_params = getattr(request, "path_params", {}) request_id = str(path_params.get("request_id") or "").strip() token = str(path_params.get("token") or "").strip() path = str(getattr(getattr(request, "url", None), "path", "")) if not request_id or not token or "/scheduling/public/" not in path: return None from govoplan_scheduling.backend.db.models import SchedulingRequest app = getattr(request, "app", None) registry = getattr(getattr(app, "state", None), "govoplan_registry", None) provider = poll_participation_gateway_provider(registry) if provider is None: return None gateway = PollResponseGatewayRef( module_id=MODULE_ID, resource_type="scheduling_request", resource_id=request_id, ) try: invitation = provider.resolve_public_invitation( session, token=token, gateway=gateway, ) except PollCapabilityError: return None scheduling_request = ( session.query(SchedulingRequest) .filter( SchedulingRequest.id == request_id, SchedulingRequest.tenant_id == invitation.tenant_id, SchedulingRequest.poll_id == invitation.poll_id, SchedulingRequest.deleted_at.is_(None), ) .one_or_none() ) return scheduling_request.tenant_id if scheduling_request is not None else None manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, version=MODULE_VERSION, dependencies=("poll",), optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow_engine", "tasks", "idm", "organizations", "addresses"), optional_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_CALENDAR_SCHEDULING, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, CAPABILITY_ACCESS_PEOPLE_SEARCH, CAPABILITY_ADDRESSES_PEOPLE_SEARCH, ), required_capabilities=( CAPABILITY_POLL_SCHEDULING, CAPABILITY_POLL_PARTICIPATION_GATEWAY, ), provides_interfaces=( ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION), ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION), ), requires_interfaces=( ModuleInterfaceRequirement(name="poll.option_ordering", version_min="0.1.11", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.11", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.11", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.11", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.11", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), frontend=FrontendModule( module_id=MODULE_ID, package_name="@govoplan/scheduling-webui", routes=( FrontendRoute( path="/scheduling", component="SchedulingPage", required_any=(READ_SCOPE,), order=56, ), ), public_routes=( PublicFrontendRoute( path="/scheduling/public/:requestId/:token", component="SchedulingPublicPage", order=10, ), ), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), view_surfaces=( ViewSurface( id="scheduling.widget.open-requests", module_id=MODULE_ID, kind="section", label="Scheduling requests widget", order=45, ), ), ), route_factory=_scheduling_router, public_tenant_resolver=_public_tenant_resolver, tenant_summary_providers=(_tenant_summary,), migration_spec=MigrationSpec( module_id=MODULE_ID, metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), retirement_supported=True, retirement_provider=drop_table_retirement_provider( scheduling_models.SchedulingRequest, scheduling_models.SchedulingCandidateSlot, scheduling_models.SchedulingParticipant, scheduling_models.SchedulingNotification, label="Scheduling", ), retirement_notes="Destructive retirement drops scheduling-owned database tables after the installer captures a database snapshot.", ), uninstall_guard_providers=( persistent_table_uninstall_guard( scheduling_models.SchedulingRequest, scheduling_models.SchedulingCandidateSlot, scheduling_models.SchedulingParticipant, scheduling_models.SchedulingNotification, label="Scheduling", ), ), documentation=DOCUMENTATION, architecture=declared_module_architecture( layer="communication_participation", kind="domain", maturity="vertical_slice", documentation_ref="README.md", test_ref="tests/test_service.py", known_limits=("Reference deployment notification delivery and every calendar-provider constraint remain incomplete.",), owned_concepts=("scheduling request", "candidate slot", "scheduling participant", "scheduling decision"), non_owned_concepts=("poll response primitive", "calendar event", "mail delivery"), recovery_docs=("README.md",), security_docs=("README.md",), ), ) def get_manifest() -> ModuleManifest: return manifest