Skip to content

Backend API

Application

main

Main FastAPI Application Desktop-only backend API for Google Calendar integrated masterplan optimisation.

validation_exception_handler async

validation_exception_handler(request: Request, exc: RequestValidationError)

Return a sanitized validation error response for malformed requests.

Source code in backend/app/main.py
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    """Return a sanitized validation error response for malformed requests."""
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={"detail": "Validation error"},
    )

check_desktop_token async

check_desktop_token(request: Request, call_next)

Require Electron's per-launch desktop token for non-exempt routes.

Source code in backend/app/main.py
@app.middleware("http")
async def check_desktop_token(request: Request, call_next):
    """Require Electron's per-launch desktop token for non-exempt routes."""
    if _DESKTOP_AUTH_TOKEN and not _is_auth_exempt(request):
        token = request.headers.get("x-desktop-token")
        if token != _DESKTOP_AUTH_TOKEN:
            return JSONResponse(status_code=403, content={"detail": "Forbidden"})
    return await call_next(request)

limit_request_body async

limit_request_body(request: Request, call_next)

Reject oversized local API requests before they reach route handlers.

Source code in backend/app/main.py
@app.middleware("http")
async def limit_request_body(request: Request, call_next):
    """Reject oversized local API requests before they reach route handlers."""
    if not request.url.path.startswith("/compute"):
        content_length = request.headers.get("content-length")
        if content_length and int(content_length) > MAX_BODY_BYTES:
            return JSONResponse(status_code=413, content={"detail": "Request body too large"})
    return await call_next(request)

health_check async

health_check()

Liveness / readiness probe.

Source code in backend/app/main.py
@app.get("/health", tags=["health"])
async def health_check():
    """Liveness / readiness probe."""
    db_ok = False
    try:
        db = SessionLocal()
        db.execute(text("SELECT 1"))
        db.close()
        db_ok = True
    except Exception:
        pass
    return {"status": "ok" if db_ok else "degraded", "version": app.version, "db": db_ok}

startup_event async

startup_event()

Initialise a current-schema database without compatibility migrations.

Source code in backend/app/main.py
@app.on_event("startup")
async def startup_event():
    """Initialise a current-schema database without compatibility migrations."""
    # Import all models to ensure they're registered with SQLAlchemy
    from app.models import (
        Person, Event, Task, Assignment,
        Location, Group, Capability, Theme, OptimizationJob,
        MasterplanLayout, GoogleCalendarConnection,
        AppSettings, EventPublishState,
        AudienceTeam, SessionElement, GeneralSchedulePublishState,
        ProcessorEvidenceKey,
    )

    inspector = inspect(engine)
    tables = set(inspector.get_table_names())
    if "persons" in tables:
        person_columns = {column["name"] for column in inspector.get_columns("persons")}
        event_columns = {column["name"] for column in inspector.get_columns("events")}
        if (
            "evidence_subject_id" not in person_columns
            or "global_data" in person_columns
            or "evidence_id" not in event_columns
        ):
            raise RuntimeError(
                "This database uses the retired desktop schema. Run the one-off "
                "convert_current_desktop_v2.py tool against a copy, then start the "
                "application with its converted output."
            )

    # Create tables only for a new or already-current database.
    Base.metadata.create_all(bind=engine)

    # Clear any stuck optimisation jobs from previous sessions
    db = SessionLocal()
    try:
        from sqlalchemy import or_
        from datetime import datetime

        stuck_jobs = db.query(OptimizationJob).filter(
            or_(
                OptimizationJob.status == "running",
                OptimizationJob.status == "pending"
            )
        ).all()

        if stuck_jobs:
            print(f"[Startup] Found {len(stuck_jobs)} stuck optimisation job(s) from previous session")
            for job in stuck_jobs:
                job.status = "failed"
                job.error_message = "Job was interrupted by application restart"
                job.completed_at = datetime.utcnow()
            db.commit()
            print(f"[Startup] Cleared all stuck optimisation jobs")

    except Exception as e:
        print(f"Error during startup: {e}")
    finally:
        db.close()

root async

root()

Health check endpoint

Source code in backend/app/main.py
@app.get("/")
async def root():
    """Health check endpoint"""
    return {
        "message": "Masterplan Optimiser API",
        "version": "2.0.0",
        "environment": settings.ENVIRONMENT,
        "mode": "Desktop"
    }

App Settings

app_settings

App Settings API Endpoints Manages application-wide configuration like Google OAuth credentials and solver tuning parameters.

SolverSettingsPayload

Bases: BaseModel

Writable solver tuning values supplied by the settings UI.

Source code in backend/app/api/v1/app_settings.py
class SolverSettingsPayload(BaseModel):
    """Writable solver tuning values supplied by the settings UI."""

    max_time_seconds: float = Field(ge=1, le=3600, default=30.0)
    break_threshold_min: int = Field(ge=1, le=240, default=30)
    break_recovery_bonus: float = Field(ge=-100, le=0, default=-3.0)
    fatigue_scale: int = Field(ge=1, le=10000, default=100)

SolverSettingsResponse

Bases: BaseModel

Solver tuning values returned to the frontend.

Source code in backend/app/api/v1/app_settings.py
class SolverSettingsResponse(BaseModel):
    """Solver tuning values returned to the frontend."""

    max_time_seconds: float
    break_threshold_min: int
    break_recovery_bonus: float
    fatigue_scale: int

GoogleOAuthPayload

Bases: BaseModel

Google OAuth client credentials saved by the desktop app.

Source code in backend/app/api/v1/app_settings.py
class GoogleOAuthPayload(BaseModel):
    """Google OAuth client credentials saved by the desktop app."""

    client_id: str
    client_secret: str

GoogleOAuthStatus

Bases: BaseModel

Configuration status for locally stored Google OAuth credentials.

Source code in backend/app/api/v1/app_settings.py
class GoogleOAuthStatus(BaseModel):
    """Configuration status for locally stored Google OAuth credentials."""

    configured: bool
    client_id_preview: str | None = None
    credential_storage_available: bool = True
    client_secret_available: bool = False

PublishTargetPayload

Bases: BaseModel

Requested publish target for schedule export actions.

Source code in backend/app/api/v1/app_settings.py
class PublishTargetPayload(BaseModel):
    """Requested publish target for schedule export actions."""

    targets: list[PublishDestination] = Field(default_factory=list, max_length=3)

PublishTargetResponse

Bases: BaseModel

Current publish target saved in local app settings.

Source code in backend/app/api/v1/app_settings.py
class PublishTargetResponse(BaseModel):
    """Current publish target saved in local app settings."""

    targets: list[PublishDestination]

ShortcutSettingsPayload

Bases: BaseModel

Keyboard shortcut override map keyed by frontend shortcut id.

Source code in backend/app/api/v1/app_settings.py
class ShortcutSettingsPayload(BaseModel):
    """Keyboard shortcut override map keyed by frontend shortcut id."""

    shortcuts: dict[str, str] = Field(default_factory=dict)

ShortcutSettingsResponse

Bases: BaseModel

Persisted keyboard shortcut override map returned to the frontend.

Source code in backend/app/api/v1/app_settings.py
class ShortcutSettingsResponse(BaseModel):
    """Persisted keyboard shortcut override map returned to the frontend."""

    shortcuts: dict[str, str]

get_solver_settings

get_solver_settings(db: Session) -> dict

Read solver settings from AppSettings table, falling back to defaults.

Source code in backend/app/api/v1/app_settings.py
def get_solver_settings(db: Session) -> dict:
    """Read solver settings from AppSettings table, falling back to defaults."""
    result = {}
    for key, default in SOLVER_DEFAULTS.items():
        row = db.query(AppSettings).filter(AppSettings.key == key).first()
        if row and row.value is not None:
            # Coerce to the correct type based on the default
            try:
                if isinstance(default, float):
                    result[key] = float(row.value)
                elif isinstance(default, int):
                    result[key] = int(float(row.value))
                else:
                    result[key] = row.value
            except (ValueError, TypeError):
                result[key] = default
        else:
            result[key] = default
    return result

get_solver_settings_endpoint async

get_solver_settings_endpoint(db: Session = Depends(get_db))

Get current solver tuning parameters.

Source code in backend/app/api/v1/app_settings.py
@router.get("/solver", response_model=SolverSettingsResponse)
async def get_solver_settings_endpoint(db: Session = Depends(get_db)):
    """Get current solver tuning parameters."""
    s = get_solver_settings(db)
    return SolverSettingsResponse(
        max_time_seconds=s["solver_max_time_seconds"],
        break_threshold_min=s["solver_break_threshold_min"],
        break_recovery_bonus=s["solver_break_recovery_bonus"],
        fatigue_scale=s["solver_fatigue_scale"],
    )

set_solver_settings async

set_solver_settings(payload: SolverSettingsPayload, db: Session = Depends(get_db))

Save solver tuning parameters.

Source code in backend/app/api/v1/app_settings.py
@router.put("/solver", response_model=SolverSettingsResponse)
async def set_solver_settings(payload: SolverSettingsPayload, db: Session = Depends(get_db)):
    """Save solver tuning parameters."""
    mapping = {
        "solver_max_time_seconds": str(payload.max_time_seconds),
        "solver_break_threshold_min": str(payload.break_threshold_min),
        "solver_break_recovery_bonus": str(payload.break_recovery_bonus),
        "solver_fatigue_scale": str(payload.fatigue_scale),
    }
    for key, value in mapping.items():
        row = db.query(AppSettings).filter(AppSettings.key == key).first()
        if row:
            row.value = value
        else:
            db.add(AppSettings(key=key, value=value))
    db.commit()
    return SolverSettingsResponse(
        max_time_seconds=payload.max_time_seconds,
        break_threshold_min=payload.break_threshold_min,
        break_recovery_bonus=payload.break_recovery_bonus,
        fatigue_scale=payload.fatigue_scale,
    )

reset_solver_settings async

reset_solver_settings(db: Session = Depends(get_db))

Reset solver parameters to defaults.

Source code in backend/app/api/v1/app_settings.py
@router.delete("/solver")
async def reset_solver_settings(db: Session = Depends(get_db)):
    """Reset solver parameters to defaults."""
    db.query(AppSettings).filter(
        AppSettings.key.in_(list(SOLVER_DEFAULTS.keys()))
    ).delete(synchronize_session=False)
    db.commit()
    return {
        "status": "success",
        "message": "Solver settings reset to defaults",
        "defaults": {
            "max_time_seconds": SOLVER_DEFAULTS["solver_max_time_seconds"],
            "break_threshold_min": SOLVER_DEFAULTS["solver_break_threshold_min"],
            "break_recovery_bonus": SOLVER_DEFAULTS["solver_break_recovery_bonus"],
            "fatigue_scale": SOLVER_DEFAULTS["solver_fatigue_scale"],
        },
    }

get_google_oauth_status async

get_google_oauth_status(db: Session = Depends(get_db))

Check whether Google OAuth credentials are configured.

Source code in backend/app/api/v1/app_settings.py
@router.get("/google-oauth", response_model=GoogleOAuthStatus)
async def get_google_oauth_status(db: Session = Depends(get_db)):
    """Check whether Google OAuth credentials are configured."""
    configured, client_id, secret_available = google_oauth_configured(db)
    preview = _mask(client_id) if client_id else None

    return GoogleOAuthStatus(
        configured=configured,
        client_id_preview=preview,
        credential_storage_available=credential_store_available(),
        client_secret_available=secret_available,
    )

set_google_oauth async

set_google_oauth(payload: GoogleOAuthPayload, db: Session = Depends(get_db))

Save or update Google OAuth credentials.

Source code in backend/app/api/v1/app_settings.py
@router.put("/google-oauth", response_model=GoogleOAuthStatus)
async def set_google_oauth(payload: GoogleOAuthPayload, db: Session = Depends(get_db)):
    """Save or update Google OAuth credentials."""
    if not payload.client_id.strip() or not payload.client_secret.strip():
        raise HTTPException(status_code=400, detail="Both client_id and client_secret are required")

    try:
        set_google_oauth_credentials(
            db,
            payload.client_id.strip(),
            payload.client_secret.strip(),
        )
    except SecureCredentialStoreUnavailable as e:
        db.rollback()
        raise HTTPException(status_code=503, detail=str(e))

    db.commit()
    return GoogleOAuthStatus(
        configured=True,
        client_id_preview=_mask(payload.client_id.strip()),
        credential_storage_available=credential_store_available(),
        client_secret_available=True,
    )

delete_google_oauth async

delete_google_oauth(db: Session = Depends(get_db))

Remove stored Google OAuth credentials.

Source code in backend/app/api/v1/app_settings.py
@router.delete("/google-oauth")
async def delete_google_oauth(db: Session = Depends(get_db)):
    """Remove stored Google OAuth credentials."""
    try:
        delete_google_oauth_credentials(db)
    except SecureCredentialStoreUnavailable as e:
        raise HTTPException(status_code=503, detail=str(e))
    db.commit()
    return {"status": "success", "message": "Google OAuth credentials removed"}

get_publish_target async

get_publish_target(db: Session = Depends(get_db))

Get the current publish target. Defaults to 'none'.

Source code in backend/app/api/v1/app_settings.py
@router.get("/publish-target", response_model=PublishTargetResponse)
async def get_publish_target(db: Session = Depends(get_db)):
    """Get the current publish target. Defaults to 'none'."""
    row = db.query(AppSettings).filter(AppSettings.key == _KEY_PUBLISH_TARGET).first()
    targets = _normalise_publish_targets(row.value if row else None)
    return PublishTargetResponse(targets=targets)

set_publish_target async

set_publish_target(payload: PublishTargetPayload, db: Session = Depends(get_db))

Set the publish target.

Source code in backend/app/api/v1/app_settings.py
@router.put("/publish-target", response_model=PublishTargetResponse)
async def set_publish_target(payload: PublishTargetPayload, db: Session = Depends(get_db)):
    """Set the publish target."""
    targets = _normalise_publish_targets(payload.targets)
    if len(targets) != len(set(payload.targets)) or len(targets) != len(payload.targets):
        raise HTTPException(
            status_code=400,
            detail="targets must contain unique values from 'google', 'mp-backend', and 'pdf'",
        )
    stored = json.dumps(targets, separators=(",", ":"))
    row = db.query(AppSettings).filter(AppSettings.key == _KEY_PUBLISH_TARGET).first()
    if row:
        row.value = stored
    else:
        db.add(AppSettings(key=_KEY_PUBLISH_TARGET, value=stored))
    db.commit()
    return PublishTargetResponse(targets=targets)

get_shortcuts async

get_shortcuts(db: Session = Depends(get_db))

Get keyboard shortcut overrides. Defaults are held by the frontend.

Source code in backend/app/api/v1/app_settings.py
@router.get("/shortcuts", response_model=ShortcutSettingsResponse)
async def get_shortcuts(db: Session = Depends(get_db)):
    """Get keyboard shortcut overrides. Defaults are held by the frontend."""
    row = db.query(AppSettings).filter(AppSettings.key == _KEY_KEYBOARD_SHORTCUTS).first()
    return ShortcutSettingsResponse(shortcuts=_load_shortcut_overrides(row))

set_shortcuts async

set_shortcuts(payload: ShortcutSettingsPayload, db: Session = Depends(get_db))

Save keyboard shortcut overrides.

Source code in backend/app/api/v1/app_settings.py
@router.put("/shortcuts", response_model=ShortcutSettingsResponse)
async def set_shortcuts(payload: ShortcutSettingsPayload, db: Session = Depends(get_db)):
    """Save keyboard shortcut overrides."""
    shortcuts = _coerce_shortcut_map(payload.shortcuts)
    encoded = json.dumps(shortcuts, sort_keys=True)
    row = db.query(AppSettings).filter(AppSettings.key == _KEY_KEYBOARD_SHORTCUTS).first()
    if row:
        row.value = encoded
    else:
        db.add(AppSettings(key=_KEY_KEYBOARD_SHORTCUTS, value=encoded))
    db.commit()
    return ShortcutSettingsResponse(shortcuts=shortcuts)

reset_shortcuts async

reset_shortcuts(db: Session = Depends(get_db))

Clear keyboard shortcut overrides.

Source code in backend/app/api/v1/app_settings.py
@router.delete("/shortcuts")
async def reset_shortcuts(db: Session = Depends(get_db)):
    """Clear keyboard shortcut overrides."""
    db.query(AppSettings).filter(AppSettings.key == _KEY_KEYBOARD_SHORTCUTS).delete(synchronize_session=False)
    db.commit()
    return {"status": "success", "message": "Keyboard shortcuts reset to defaults"}

Data Management

data_management

Data Management API - export, import, copy-from-event, delete-event, factory-reset.

ExportRequest

Bases: BaseModel

Data export request selecting full, global-only, or event-only scope.

Source code in backend/app/api/v1/data_management.py
class ExportRequest(BaseModel):
    """Data export request selecting full, global-only, or event-only scope."""

    scope: str = "full"  # "full" | "global" | "event"
    event_ids: Optional[List[int]] = None

ImportRequest

Bases: BaseModel

Portable backup/import payload produced by the export endpoint.

Source code in backend/app/api/v1/data_management.py
class ImportRequest(BaseModel):
    """Portable backup/import payload produced by the export endpoint."""

    data: Dict[str, Any]

ImportValidationIssue

Bases: BaseModel

Structured import validation message shown before import.

Source code in backend/app/api/v1/data_management.py
class ImportValidationIssue(BaseModel):
    """Structured import validation message shown before import."""

    id: str
    severity: str
    title: str
    message: str
    path: Optional[str] = None

ImportPreviewSummary

Bases: BaseModel

Human-readable import contents summary.

Source code in backend/app/api/v1/data_management.py
class ImportPreviewSummary(BaseModel):
    """Human-readable import contents summary."""

    projectName: Optional[str] = None
    eventName: Optional[str] = None
    dateRange: Optional[str] = None
    sourceVersion: Optional[str] = None
    exportedAt: Optional[str] = None
    peopleCount: int = 0
    locationCount: int = 0
    groupCount: int = 0
    taskCount: int = 0
    templateCount: int = 0
    taskTypeCount: int = 0
    assignmentCount: int = 0
    hasOptimisedSchedule: bool = False
    hasFinalSchedule: bool = False
    hasPublishMetadata: bool = False
    hasAppSettings: bool = False
    importType: str = "unknown"

ImportValidationResult

Bases: BaseModel

Validation result used by the import preview UI.

Source code in backend/app/api/v1/data_management.py
class ImportValidationResult(BaseModel):
    """Validation result used by the import preview UI."""

    isValid: bool
    errors: List[ImportValidationIssue]
    warnings: List[ImportValidationIssue]
    info: List[ImportValidationIssue]
    summary: ImportPreviewSummary

CopyFromEventRequest

Bases: BaseModel

Request to copy selected setup data from one event into another.

Source code in backend/app/api/v1/data_management.py
class CopyFromEventRequest(BaseModel):
    """Request to copy selected setup data from one event into another."""

    source_event_id: int
    target_event_id: int
    include: List[str]  # "persons", "locations", "groups", "task_structure", "enabled_capabilities"

CopiedTaskDateRepairRequest

Bases: BaseModel

Identify a source and target event for copied task-date repair.

Source code in backend/app/api/v1/data_management.py
class CopiedTaskDateRepairRequest(BaseModel):
    """Identify a source and target event for copied task-date repair."""

    source_event_id: int
    target_event_id: int

ApplyCopiedTaskDateRepairRequest

Bases: CopiedTaskDateRepairRequest

Apply the selected copied task-date repairs after revalidation.

Source code in backend/app/api/v1/data_management.py
class ApplyCopiedTaskDateRepairRequest(CopiedTaskDateRepairRequest):
    """Apply the selected copied task-date repairs after revalidation."""

    task_instance_ids: List[int]

CopiedTaskDateRepairCandidate

Bases: BaseModel

One copied task skeleton and its proposed target-event date.

Source code in backend/app/api/v1/data_management.py
class CopiedTaskDateRepairCandidate(BaseModel):
    """One copied task skeleton and its proposed target-event date."""

    task_instance_id: int
    name: str
    current_date: str
    proposed_date: Optional[str] = None
    proposed_day_index: Optional[int] = None
    repairable: bool
    reason: Optional[str] = None

CopiedTaskDateRepairPreview

Bases: BaseModel

Preview of safe copied task-date repairs for one event pair.

Source code in backend/app/api/v1/data_management.py
class CopiedTaskDateRepairPreview(BaseModel):
    """Preview of safe copied task-date repairs for one event pair."""

    source_event_id: int
    target_event_id: int
    candidates: List[CopiedTaskDateRepairCandidate]
    repairable_count: int

TaskDateMappingError

Bases: ValueError

Raised when a source task cannot be represented in the target event.

Source code in backend/app/api/v1/data_management.py
class TaskDateMappingError(ValueError):
    """Raised when a source task cannot be represented in the target event."""

FactoryResetRequest

Bases: BaseModel

Confirmation payload required before wiping all local app data.

Source code in backend/app/api/v1/data_management.py
class FactoryResetRequest(BaseModel):
    """Confirmation payload required before wiping all local app data."""

    confirmation: str

export_data async

export_data(req: ExportRequest, db: Session = Depends(get_db))

Export data as JSON.

scope=full → global settings + all events (type="full_backup") scope=global → global settings only (type="app_settings") scope=event → global settings + 1+ events (type="project")

Source code in backend/app/api/v1/data_management.py
@router.post("/export")
async def export_data(req: ExportRequest, db: Session = Depends(get_db)):
    """Export data as JSON.

    scope=full  → global settings + all events  (type="full_backup")
    scope=global → global settings only          (type="app_settings")
    scope=event  → global settings + 1+ events   (type="project")
    """
    result: Dict[str, Any] = {"version": EXPORT_VERSION}

    # Global data is ALWAYS included  -  events are never exported without it
    result["global_data"] = _serialize_global(db)

    if req.scope == "global":
        result["type"] = "app_settings"
    elif req.scope == "event":
        if not req.event_ids:
            raise HTTPException(400, "event_ids required when scope=event")
        result["type"] = "project"
        events = db.query(Event).filter(Event.id.in_(req.event_ids)).all()
        result["events"] = [_serialize_event(db, e) for e in events]
    else:  # full
        result["type"] = "full_backup"
        result["events"] = [_serialize_event(db, e) for e in db.query(Event).all()]

    return result

validate_import_payload

validate_import_payload(payload: Any) -> ImportValidationResult

Validate an import payload and build a preview without mutating data.

Source code in backend/app/api/v1/data_management.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
def validate_import_payload(payload: Any) -> ImportValidationResult:
    """Validate an import payload and build a preview without mutating data."""
    errors: list[ImportValidationIssue] = []
    warnings: list[ImportValidationIssue] = []
    info: list[ImportValidationIssue] = []
    summary = ImportPreviewSummary()

    if not isinstance(payload, dict):
        errors.append(_issue(
            "error",
            "Unsupported import format",
            "The import file must contain a JSON object exported by this app.",
        ))
        return ImportValidationResult(isValid=False, errors=errors, warnings=[], info=[], summary=summary)

    import_type = payload.get("type", "unknown")
    summary.importType = str(import_type or "unknown")
    if import_type not in ("full_backup", "project", "app_settings", "unknown"):
        errors.append(_issue(
            "error",
            "Unsupported import format",
            f"Import type '{import_type}' is not supported by this app.",
            "type",
        ))
    elif import_type == "unknown":
        errors.append(_issue(
            "error",
            "Missing import type",
            "Only a current export with an explicit import type can be imported.",
            "type",
        ))

    version = payload.get("version")
    if version is None:
        errors.append(_issue(
            "error",
            "Missing file version",
            "Only a current version 2 export can be imported. Convert the one retained database with the standalone conversion tool.",
            "version",
        ))
    elif not isinstance(version, int):
        errors.append(_issue(
            "error",
            "Invalid file version",
            "The export version must be a number.",
            "version",
        ))
    elif version > EXPORT_VERSION:
        errors.append(_issue(
            "error",
            "File version too new",
            f"This file was exported with version {version}, but this app supports version {EXPORT_VERSION}.",
            "version",
        ))
    elif version < EXPORT_VERSION:
        errors.append(_issue(
            "error",
            "Older file version is unsupported",
            "This app has no built-in legacy import. Convert the one retained database with the standalone conversion tool.",
            "version",
        ))
    if version is not None:
        summary.sourceVersion = str(version)
        info.append(_issue("info", "File version", f"Export version {version}.", "version"))

    exported_at = payload.get("exported_at") or payload.get("exportedAt")
    if exported_at:
        summary.exportedAt = str(exported_at)
        info.append(_issue("info", "Export timestamp", f"Exported at {exported_at}.", "exported_at"))

    global_data = payload.get("global_data")
    if not isinstance(global_data, dict):
        errors.append(_issue(
            "error",
            "Missing application settings",
            "The file is missing the required global_data section.",
            "global_data",
        ))
        global_data = {}
    else:
        summary.hasAppSettings = True

    global_id_sets = {
        "task_types": _id_set(_rows(global_data, "task_types")),
        "task_templates": _id_set(_rows(global_data, "task_templates")),
        "capabilities": _id_set(_rows(global_data, "capabilities")),
        "group_types": _id_set(_rows(global_data, "group_types")),
        "group_roles": _id_set(_rows(global_data, "group_roles")),
        "assignment_sources": _id_set(_rows(global_data, "assignment_sources")),
    }
    summary.templateCount = len(_rows(global_data, "task_templates"))
    summary.taskTypeCount = len(_rows(global_data, "task_types"))

    for key in (
        "task_types",
        "capability_types",
        "capabilities",
        "task_templates",
        "group_types",
        "leadership_levels",
        "group_roles",
        "assignment_sources",
        "calendar_export_formats",
    ):
        _check_duplicate_ids(_rows(global_data, key), key, f"global_data.{key}", errors)

    events = payload.get("events", [])
    if events is None:
        events = []
    if not isinstance(events, list):
        errors.append(_issue(
            "error",
            "Invalid projects section",
            "The events section must be a list.",
            "events",
        ))
        events = []

    _check_imported_rich_templates(global_data, events, errors)

    first_event_name: Optional[str] = None
    first_start: Optional[str] = None
    first_end: Optional[str] = None
    total_assignments = 0
    schedule_without_metadata = False
    publish_metadata_found = False

    for event_index, event_data in enumerate(events):
        event_path = f"events[{event_index}]"
        if not isinstance(event_data, dict):
            errors.append(_issue(
                "error",
                "Invalid project entry",
                "Each project entry must be an object.",
                event_path,
            ))
            continue

        event_row = event_data.get("event")
        if not isinstance(event_row, dict):
            errors.append(_issue(
                "error",
                "Missing project identity",
                "Each imported project must contain an event object.",
                f"{event_path}.event",
            ))
            continue

        event_name = str(event_row.get("name") or "").strip()
        if not event_name:
            errors.append(_issue(
                "error",
                "Missing project name",
                "Each imported project must have a name.",
                f"{event_path}.event.name",
            ))
        elif first_event_name is None:
            first_event_name = event_name
            summary.projectName = event_name
            summary.eventName = event_name

        start_date = event_row.get("start_date")
        end_date = event_row.get("end_date")
        if first_start is None and isinstance(start_date, str):
            first_start = start_date
        if first_end is None and isinstance(end_date, str):
            first_end = end_date
        for field_name, raw_value in (("start_date", start_date), ("end_date", end_date)):
            if raw_value:
                try:
                    date.fromisoformat(str(raw_value))
                except ValueError:
                    errors.append(_issue(
                        "error",
                        "Invalid project date",
                        f"{field_name} must be an ISO date such as 2026-08-01.",
                        f"{event_path}.event.{field_name}",
                    ))
        if start_date and end_date:
            try:
                if date.fromisoformat(str(end_date)) < date.fromisoformat(str(start_date)):
                    errors.append(_issue(
                        "error",
                        "Invalid date range",
                        "The project end date must not be before the start date.",
                        f"{event_path}.event.end_date",
                    ))
            except ValueError:
                pass

        if any(event_row.get(key) for key in ("google_calendar_id", "mp_backend_url")):
            publish_metadata_found = True

        locations = _rows(event_data, "locations")
        persons = _rows(event_data, "persons")
        tasks = _rows(event_data, "tasks")
        task_instances = _rows(event_data, "task_instances")
        groups = _rows(event_data, "groups")
        assignments = _rows(event_data, "assignments")
        group_memberships = _rows(event_data, "group_memberships")
        task_capability_requirements = _rows(event_data, "task_capability_requirements")
        person_capabilities = _rows(event_data, "person_capabilities")
        masterplan_layouts = _rows(event_data, "masterplan_layouts")
        optimization_jobs = _rows(event_data, "optimization_jobs")
        audience_teams = _rows(event_data, "audience_teams")
        schedule_views = _rows(event_data, "schedule_views")
        session_elements = _rows(event_data, "session_elements")

        summary.locationCount += len(locations)
        summary.peopleCount += len(persons)
        summary.groupCount += len(groups)
        summary.taskCount += len(tasks) + len(task_instances)
        total_assignments += len(assignments)

        for key, rows in (
            ("locations", locations),
            ("persons", persons),
            ("tasks", tasks),
            ("task_instances", task_instances),
            ("groups", groups),
            ("assignments", assignments),
            ("group_memberships", group_memberships),
            ("task_capability_requirements", task_capability_requirements),
            ("person_capabilities", person_capabilities),
            ("masterplan_layouts", masterplan_layouts),
            ("optimization_jobs", optimization_jobs),
            ("audience_teams", audience_teams),
            ("schedule_views", schedule_views),
            ("session_elements", session_elements),
        ):
            _check_duplicate_ids(rows, key, f"{event_path}.{key}", errors)

        location_ids = _id_set(locations)
        person_ids = _id_set(persons)
        task_ids = _id_set(tasks)
        task_instance_ids = _id_set(task_instances)
        all_task_ids = task_ids | task_instance_ids
        group_ids = _id_set(groups)
        audience_team_ids = _id_set(audience_teams)
        schedule_view_ids = _id_set(schedule_views)
        assigned_task_ids = {row.get("task_id") for row in assignments if isinstance(row, dict) and row.get("task_id") is not None}
        grouped_person_ids = {row.get("person_id") for row in group_memberships if isinstance(row, dict)}

        if not persons:
            warnings.append(_issue(
                "warning",
                "No people included",
                f"{event_name or 'This project'} has no people in the import.",
                f"{event_path}.persons",
            ))
        if not tasks and not task_instances:
            warnings.append(_issue(
                "warning",
                "No tasks included",
                f"{event_name or 'This project'} has no tasks in the import.",
                f"{event_path}.tasks",
            ))

        for idx, loc in enumerate(locations):
            if isinstance(loc, dict) and not str(loc.get("name") or "").strip():
                warnings.append(_issue(
                    "warning",
                    "Location missing name",
                    "A location is missing its display name. A default name may be used.",
                    f"{event_path}.locations[{idx}].name",
                ))

        for idx, person in enumerate(persons):
            if not isinstance(person, dict):
                continue
            home_location_id = person.get("home_location_id")
            if home_location_id is not None and home_location_id not in location_ids:
                errors.append(_issue(
                    "error",
                    "Person references missing location",
                    "A person's home location does not exist in the imported project.",
                    f"{event_path}.persons[{idx}].home_location_id",
                ))
            if group_memberships and person.get("id") not in grouped_person_ids:
                warnings.append(_issue(
                    "warning",
                    "Person without group",
                    "A person is not part of any imported group.",
                    f"{event_path}.persons[{idx}]",
                ))

        for idx, task in enumerate(tasks):
            if not isinstance(task, dict):
                continue
            template_id = task.get("task_template_id")
            task_type_id = task.get("task_type_id")
            if template_id is not None and template_id not in global_id_sets["task_templates"]:
                errors.append(_issue(
                    "error",
                    "Task references missing template",
                    "A task references a task template that is not included in the import.",
                    f"{event_path}.tasks[{idx}].task_template_id",
                ))
            if task_type_id is not None and task_type_id not in global_id_sets["task_types"]:
                errors.append(_issue(
                    "error",
                    "Task references missing task type",
                    "A task references a task type that is not included in the import.",
                    f"{event_path}.tasks[{idx}].task_type_id",
                ))
            if task.get("id") not in assigned_task_ids:
                warnings.append(_issue(
                    "warning",
                    "Task without assigned people",
                    "A task has no imported person assignments.",
                    f"{event_path}.tasks[{idx}]",
                ))
            if not _task_has_location_value(task):
                warnings.append(_issue(
                    "warning",
                    "Task without location",
                    "A task has no imported location information.",
                    f"{event_path}.tasks[{idx}]",
                ))
            if task.get("optimised"):
                summary.hasOptimisedSchedule = True
            if task.get("final"):
                summary.hasFinalSchedule = True

        for idx, task in enumerate(task_instances):
            if not isinstance(task, dict):
                continue
            template_id = task.get("template_id")
            task_type_id = task.get("task_type_id")
            if template_id is not None and template_id not in global_id_sets["task_templates"]:
                errors.append(_issue(
                    "error",
                    "Task instance references missing template",
                    "A task instance references a task template that is not included in the import.",
                    f"{event_path}.task_instances[{idx}].template_id",
                ))
            if task_type_id is not None and task_type_id not in global_id_sets["task_types"]:
                errors.append(_issue(
                    "error",
                    "Task instance references missing task type",
                    "A task instance references a task type that is not included in the import.",
                    f"{event_path}.task_instances[{idx}].task_type_id",
                ))
            if not _task_has_location_value(task):
                warnings.append(_issue(
                    "warning",
                    "Task without location",
                    "A task has no imported location information.",
                    f"{event_path}.task_instances[{idx}]",
                ))
            if task.get("optimised"):
                summary.hasOptimisedSchedule = True
            if task.get("final"):
                summary.hasFinalSchedule = True

        for idx, assignment in enumerate(assignments):
            if not isinstance(assignment, dict):
                continue
            if assignment.get("person_id") not in person_ids:
                errors.append(_issue(
                    "error",
                    "Assignment references missing person",
                    "An assignment references a person that is not included in the import.",
                    f"{event_path}.assignments[{idx}].person_id",
                ))
            task_id = assignment.get("task_id")
            if task_id is not None and task_id not in all_task_ids:
                errors.append(_issue(
                    "error",
                    "Assignment references missing task",
                    "An assignment references a task that is not included in the import.",
                    f"{event_path}.assignments[{idx}].task_id",
                ))
            source_id = assignment.get("assignment_source_id")
            if source_id is not None and source_id not in global_id_sets["assignment_sources"]:
                errors.append(_issue(
                    "error",
                    "Assignment references missing source",
                    "An assignment references an assignment source that is not included in the import.",
                    f"{event_path}.assignments[{idx}].assignment_source_id",
                ))

        for idx, membership in enumerate(group_memberships):
            if not isinstance(membership, dict):
                continue
            if membership.get("group_id") not in group_ids:
                errors.append(_issue(
                    "error",
                    "Group membership references missing group",
                    "A group membership references a group that is not included in the import.",
                    f"{event_path}.group_memberships[{idx}].group_id",
                ))
            if membership.get("person_id") not in person_ids:
                errors.append(_issue(
                    "error",
                    "Group membership references missing person",
                    "A group membership references a person that is not included in the import.",
                    f"{event_path}.group_memberships[{idx}].person_id",
                ))
            if membership.get("group_role_id") not in global_id_sets["group_roles"]:
                errors.append(_issue(
                    "error",
                    "Group membership references missing role",
                    "A group membership references a group role that is not included in the import.",
                    f"{event_path}.group_memberships[{idx}].group_role_id",
                ))

        for idx, requirement in enumerate(task_capability_requirements):
            if not isinstance(requirement, dict):
                continue
            if requirement.get("task_id") not in task_ids:
                errors.append(_issue(
                    "error",
                    "Capability requirement references missing task",
                    "A task capability requirement references a missing task.",
                    f"{event_path}.task_capability_requirements[{idx}].task_id",
                ))
            if requirement.get("capability_id") not in global_id_sets["capabilities"]:
                errors.append(_issue(
                    "error",
                    "Capability requirement references missing capability",
                    "A task capability requirement references a missing capability.",
                    f"{event_path}.task_capability_requirements[{idx}].capability_id",
                ))

        for idx, capability in enumerate(person_capabilities):
            if not isinstance(capability, dict):
                continue
            if capability.get("person_id") not in person_ids:
                errors.append(_issue(
                    "error",
                    "Person capability references missing person",
                    "A person capability references a missing person.",
                    f"{event_path}.person_capabilities[{idx}].person_id",
                ))
            if capability.get("capability_id") not in global_id_sets["capabilities"]:
                errors.append(_issue(
                    "error",
                    "Person capability references missing capability",
                    "A person capability references a missing capability.",
                    f"{event_path}.person_capabilities[{idx}].capability_id",
                ))

        for idx, layout in enumerate(masterplan_layouts):
            if isinstance(layout, dict) and layout.get("task_id") is not None and layout.get("task_id") not in all_task_ids:
                errors.append(_issue(
                    "error",
                    "Layout references missing task",
                    "A schedule layout entry references a task that is not included in the import.",
                    f"{event_path}.masterplan_layouts[{idx}].task_id",
                ))

        for idx, element in enumerate(session_elements):
            if not isinstance(element, dict):
                continue
            if not str(element.get("title") or "").strip():
                errors.append(_issue(
                    "error",
                    "Session Element missing title",
                    "A Session Element is missing its title.",
                    f"{event_path}.session_elements[{idx}].title",
                ))
            location_id = element.get("location_id")
            if location_id is not None and location_id not in location_ids:
                warnings.append(_issue(
                    "warning",
                    "Session Element references missing location",
                    "A Session Element references a location that is not included; the location will be cleared.",
                    f"{event_path}.session_elements[{idx}].location_id",
                ))
            responsible_person_id = element.get("responsible_person_id")
            if responsible_person_id is not None and responsible_person_id not in person_ids:
                warnings.append(_issue(
                    "warning",
                    "Session Element references missing responsible person",
                    "A Session Element references a responsible person that is not included; the person reference will be cleared.",
                    f"{event_path}.session_elements[{idx}].responsible_person_id",
                ))
            team_ids = element.get("attendee_team_ids") or []
            if not isinstance(team_ids, list):
                errors.append(_issue(
                    "error",
                    "Session Element has invalid audience teams",
                    "A Session Element audience team list must be an array.",
                    f"{event_path}.session_elements[{idx}].attendee_team_ids",
                ))
            elif team_ids:
                missing_team_ids = [team_id for team_id in team_ids if team_id not in audience_team_ids]
                if missing_team_ids:
                    warnings.append(_issue(
                        "warning",
                        "Session Element references missing team",
                        "A Session Element references an Audience Team that is not included; the missing team will be ignored.",
                        f"{event_path}.session_elements[{idx}].attendee_team_ids",
                    ))
            view_ids = element.get("schedule_view_ids") or []
            if not isinstance(view_ids, list):
                errors.append(_issue(
                    "error",
                    "Session Element has invalid schedule views",
                    "A Session Element schedule view list must be an array.",
                    f"{event_path}.session_elements[{idx}].schedule_view_ids",
                ))
            elif view_ids:
                missing_view_ids = [view_id for view_id in view_ids if view_id not in schedule_view_ids]
                if missing_view_ids:
                    warnings.append(_issue(
                        "warning",
                        "Session Element references missing schedule view",
                        "A Session Element references a schedule view that is not included; the missing view will be ignored.",
                        f"{event_path}.session_elements[{idx}].schedule_view_ids",
                    ))

        if (summary.hasOptimisedSchedule or summary.hasFinalSchedule) and not optimization_jobs:
            schedule_without_metadata = True

    summary.assignmentCount = total_assignments
    summary.dateRange = _format_import_date_range(first_start, first_end)
    summary.hasPublishMetadata = publish_metadata_found

    if schedule_without_metadata:
        warnings.append(_issue(
            "warning",
            "Schedule without optimisation metadata",
            "Imported schedule data exists, but no optimisation job metadata is included.",
            "events",
        ))

    if publish_metadata_found:
        warnings.append(_issue(
            "warning",
            "Reconnect integrations after import",
            "Publish metadata was found, but credentials and secrets are not imported from project JSON.",
            "events",
        ))

    if not events and import_type != "app_settings":
        warnings.append(_issue(
            "warning",
            "No projects included",
            "This file does not contain any projects. It may only update global configuration.",
            "events",
        ))

    return ImportValidationResult(
        isValid=not errors,
        errors=errors,
        warnings=warnings,
        info=info,
        summary=summary,
    )

preview_import_data async

preview_import_data(req: ImportRequest)

Validate an import payload and return a safe preview summary.

Source code in backend/app/api/v1/data_management.py
@router.post("/import/preview", response_model=ImportValidationResult)
async def preview_import_data(req: ImportRequest):
    """Validate an import payload and return a safe preview summary."""
    return validate_import_payload(req.data)

import_data async

import_data(req: ImportRequest, db: Session = Depends(get_db))

Import data from a previously exported JSON payload.

The file always contains global_data (always imported first). Events are imported after global data, with FK remapping.

Source code in backend/app/api/v1/data_management.py
@router.post("/import")
async def import_data(req: ImportRequest, db: Session = Depends(get_db)):
    """Import data from a previously exported JSON payload.

    The file always contains global_data (always imported first).
    Events are imported after global data, with FK remapping.
    """
    payload = req.data
    validation = validate_import_payload(payload)
    if validation.errors:
        raise HTTPException(
            400,
            {
                "message": "Import validation failed",
                "errors": [issue.model_dump() for issue in validation.errors],
            },
        )
    _reject_accountability_identity_conflicts(db, payload)

    try:
        imported_event_ids: list[int] = []

        # Always import global data first
        global_maps = _import_global(db, payload["global_data"])

        # Then import events if present
        if "events" in payload:
            for event_data in payload["events"]:
                new_event = _import_event(db, event_data, global_maps)
                if new_event is not None:
                    imported_event_ids.append(new_event.id)

        db.commit()
    except HTTPException:
        raise
    except Exception as exc:
        db.rollback()
        logger.error(f"Import failed: {exc}", exc_info=True)
        raise HTTPException(500, f"Import failed: {exc}")

    # Build descriptive message
    file_type = payload.get("type", "unknown")
    parts = ["application settings"]
    if imported_event_ids:
        n = len(imported_event_ids)
        parts.append(f"{n} project{'s' if n != 1 else ''}")
    msg = "Imported " + " and ".join(parts) + "."

    return {
        "status": "ok",
        "message": msg,
        "imported_event_ids": imported_event_ids,
    }

copy_from_event async

copy_from_event(req: CopyFromEventRequest, db: Session = Depends(get_db))

Clone selected data from one event into another (internal DB copy).

Source code in backend/app/api/v1/data_management.py
@router.post("/copy-from-event")
async def copy_from_event(req: CopyFromEventRequest, db: Session = Depends(get_db)):
    """Clone selected data from one event into another (internal DB copy)."""
    source, target = _load_copy_events(db, req.source_event_id, req.target_event_id)

    summary: Dict[str, int] = {}
    mapped_task_instances: List[tuple[TaskInstance, str, int]] = []

    if "task_structure" in req.include:
        source_task_instances = (
            db.query(TaskInstance).filter(TaskInstance.event_id == source.id).all()
        )
        templates = _template_map(db, source_task_instances)
        try:
            for instance in source_task_instances:
                mapped_date, mapped_day_index = _map_task_date(
                    instance,
                    source,
                    target,
                    templates.get(instance.template_id),
                )
                mapped_task_instances.append(
                    (instance, mapped_date, mapped_day_index)
                )
        except TaskDateMappingError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    try:
        loc_map: dict = {}
        person_map: dict = {}

        # Locations
        if "locations" in req.include:
            src_locations = db.query(Location).filter(Location.event_id == source.id).all()
            for loc in src_locations:
                new_loc = Location(
                    event_id=target.id, name=loc.name, address=loc.address, details=loc.details
                )
                db.add(new_loc)
                db.flush()
                loc_map[loc.id] = new_loc.id
            summary["locations"] = len(src_locations)

        # Persons (+ capabilities)
        if "persons" in req.include:
            src_persons = db.query(Person).filter(Person.event_id == source.id).all()
            for p in src_persons:
                home_loc = loc_map.get(p.home_location_id) if p.home_location_id else None
                new_p = Person(
                    event_id=target.id,
                    first_name=p.first_name, last_name=p.last_name,
                    email=p.email, phone=p.phone, google_email=p.google_email,
                    max_hours_per_day=p.max_hours_per_day,
                    home_location_id=home_loc,
                )
                db.add(new_p)
                db.flush()
                person_map[p.id] = new_p.id

                # Copy person capabilities
                src_caps = db.query(PersonCapability).filter(PersonCapability.person_id == p.id).all()
                for pc in src_caps:
                    db.add(PersonCapability(
                        person_id=new_p.id,
                        capability_id=pc.capability_id,
                        level=pc.level, notes=pc.notes,
                    ))
                for interval in db.query(PersonUnavailability).filter(
                    PersonUnavailability.event_id == source.id,
                    PersonUnavailability.person_id == p.id,
                ).all():
                    db.add(PersonUnavailability(
                        event_id=target.id,
                        person_id=new_p.id,
                        starts_at=interval.starts_at,
                        ends_at=interval.ends_at,
                    ))
            summary["persons"] = len(src_persons)

        # Groups (+ memberships)
        if "groups" in req.include:
            src_groups = db.query(Group).filter(Group.event_id == source.id).all()
            group_map: dict = {}
            for g in src_groups:
                new_g = Group(
                    event_id=target.id, group_type_id=g.group_type_id,
                    name=g.name, meta_data=g.meta_data,
                )
                db.add(new_g)
                db.flush()
                group_map[g.id] = new_g.id

            # Memberships - only if persons were also copied (need person_map)
            if person_map:
                for g in src_groups:
                    mems = db.query(GroupMembership).filter(GroupMembership.group_id == g.id).all()
                    for m in mems:
                        new_pid = person_map.get(m.person_id)
                        if new_pid:
                            db.add(GroupMembership(
                                group_id=group_map[g.id],
                                person_id=new_pid,
                                group_role_id=m.group_role_id,
                                membership_data=m.membership_data,
                            ))
            summary["groups"] = len(src_groups)

        # Task structure (task_instances - skeleton only, no optimised/final/assignments)
        if "task_structure" in req.include:
            for ti, mapped_date, mapped_day_index in mapped_task_instances:
                db.add(TaskInstance(
                    event_id=target.id,
                    name=ti.name,
                    template_id=ti.template_id,
                    task_type_id=ti.task_type_id,
                    date=mapped_date,
                    day_index=mapped_day_index,
                    is_floating=ti.is_floating,
                    is_transfer=ti.is_transfer,
                    field_values=ti.field_values,
                    constraints=ti.constraints,
                    additional=ti.additional,
                    # omit optimised and final
                ))
            summary["task_instances"] = len(mapped_task_instances)

        # Enabled capabilities
        if "enabled_capabilities" in req.include:
            target.enabled_capability_ids = source.enabled_capability_ids
            summary["enabled_capabilities_copied"] = 1

        db.commit()
    except Exception as exc:
        db.rollback()
        logger.error(f"Copy-from-event failed: {exc}", exc_info=True)
        raise HTTPException(500, f"Copy failed: {exc}")

    return {"status": "ok", "summary": summary}

preview_copied_task_date_repair async

preview_copied_task_date_repair(req: CopiedTaskDateRepairRequest, db: Session = Depends(get_db))

Preview safe date repairs for task skeletons copied between events.

Source code in backend/app/api/v1/data_management.py
@router.post(
    "/copy-from-event/repair-preview",
    response_model=CopiedTaskDateRepairPreview,
)
async def preview_copied_task_date_repair(
    req: CopiedTaskDateRepairRequest,
    db: Session = Depends(get_db),
):
    """Preview safe date repairs for task skeletons copied between events."""
    source, target = _load_copy_events(
        db,
        req.source_event_id,
        req.target_event_id,
    )
    return _build_copied_task_date_repair_preview(db, source, target)

apply_copied_task_date_repair async

apply_copied_task_date_repair(req: ApplyCopiedTaskDateRepairRequest, db: Session = Depends(get_db))

Apply selected copied task-date repairs after revalidating the preview.

Source code in backend/app/api/v1/data_management.py
@router.post("/copy-from-event/repair")
async def apply_copied_task_date_repair(
    req: ApplyCopiedTaskDateRepairRequest,
    db: Session = Depends(get_db),
):
    """Apply selected copied task-date repairs after revalidating the preview."""
    source, target = _load_copy_events(
        db,
        req.source_event_id,
        req.target_event_id,
    )
    preview = _build_copied_task_date_repair_preview(db, source, target)
    candidates = {
        candidate.task_instance_id: candidate
        for candidate in preview.candidates
        if candidate.repairable
    }
    requested_ids = list(dict.fromkeys(req.task_instance_ids))
    missing_ids = [
        task_id for task_id in requested_ids if task_id not in candidates
    ]
    if missing_ids:
        raise HTTPException(
            status_code=409,
            detail=(
                "The repair preview is stale. Review the copied task dates "
                "again before applying changes."
            ),
        )

    instances = (
        db.query(TaskInstance)
        .filter(TaskInstance.event_id == target.id, TaskInstance.id.in_(requested_ids))
        .all()
        if requested_ids
        else []
    )
    instances_by_id = {instance.id: instance for instance in instances}
    for task_id in requested_ids:
        candidate = candidates[task_id]
        instance = instances_by_id.get(task_id)
        if not instance or str(instance.date) != candidate.current_date:
            db.rollback()
            raise HTTPException(
                status_code=409,
                detail=(
                    "The repair preview is stale. Review the copied task dates "
                    "again before applying changes."
                ),
            )
        instance.date = candidate.proposed_date
        instance.day_index = candidate.proposed_day_index

    db.commit()
    return {
        "status": "ok",
        "repaired_count": len(requested_ids),
        "task_instance_ids": requested_ids,
    }

delete_event async

delete_event(event_id: int, db: Session = Depends(get_db))

Delete a single event and all its scoped data.

Source code in backend/app/api/v1/data_management.py
@router.delete("/event/{event_id}")
async def delete_event(event_id: int, db: Session = Depends(get_db)):
    """Delete a single event and all its scoped data."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(404, "Event not found")

    event_name = event.name
    try:
        delete_event_scoped_data(db, event_id)
        db.commit()
    except Exception as exc:
        db.rollback()
        logger.error(f"Error deleting event {event_id}: {exc}")
        raise HTTPException(500, f"Failed to delete event: {exc}")

    db.expire_all()
    return {"status": "ok", "message": f"Event '{event_name}' deleted"}

factory_reset async

factory_reset(req: FactoryResetRequest, db: Session = Depends(get_db))

Wipe ALL data and recreate default theme. Requires confirmation='RESET'.

Source code in backend/app/api/v1/data_management.py
@router.post("/factory-reset")
async def factory_reset(req: FactoryResetRequest, db: Session = Depends(get_db)):
    """Wipe ALL data and recreate default theme. Requires confirmation='RESET'."""
    if req.confirmation != "RESET":
        raise HTTPException(400, "You must send confirmation='RESET' to proceed")

    try:
        # Delete in FK-safe order (leaves first)
        tables_ordered = [
            "assignments",
            "optimization_jobs",
            "event_publish_states",
            "general_schedule_publish_states",
            "task_descriptions",
            "masterplan_layouts",
            "task_capability_requirements",
            "session_elements",
            "session_element_types",
            "schedule_views",
            "audience_teams",
            "audience_categories",
            "tasks",
            "person_unavailability",
            "person_capabilities",
            "group_memberships",
            "groups",
            "persons",
            "locations",
            "attachments",
            "task_instances",
            "events",
            # Global tables
            "calendar_export_formats",
            "group_roles",
            "group_types",
            "leadership_levels",
            "assignment_sources",
            "task_templates",
            "capabilities",
            "capability_types",
            "task_types",
            "google_calendar_connections",
            "themes",
            "app_settings",
        ]
        for table in tables_ordered:
            try:
                db.execute(text(f"DELETE FROM {table}"))
            except Exception:
                pass  # table may not exist yet

        # Recreate default theme
        default_theme = Theme(name="Default Theme", is_active=True)
        db.add(default_theme)

        db.commit()
    except Exception as exc:
        db.rollback()
        logger.error(f"Factory reset failed: {exc}", exc_info=True)
        raise HTTPException(500, f"Factory reset failed: {exc}")

    db.expire_all()
    return {"status": "ok", "message": "Factory reset complete. Default theme restored."}

Events

events

Events API Endpoints

EventCreate

Bases: BaseModel

Payload for creating or replacing an event record.

Source code in backend/app/api/v1/events.py
class EventCreate(BaseModel):
    """Payload for creating or replacing an event record."""

    name: str
    location: str
    start_date: date
    end_date: date
    meta_data: Optional[Dict[str, Any]] = None

EventCalendarUpdate

Bases: BaseModel

Payload for updating the Google Calendar linked to an event.

Source code in backend/app/api/v1/events.py
class EventCalendarUpdate(BaseModel):
    """Payload for updating the Google Calendar linked to an event."""

    google_calendar_id: Optional[str] = None

PdfExportSettingsUpdate

Bases: BaseModel

Event-specific presentation title used for local PDF exports.

Source code in backend/app/api/v1/events.py
class PdfExportSettingsUpdate(BaseModel):
    """Event-specific presentation title used for local PDF exports."""

    title: str

EnabledCapabilitiesUpdate

Bases: BaseModel

Event-scoped capability availability payload.

Source code in backend/app/api/v1/events.py
class EnabledCapabilitiesUpdate(BaseModel):
    """Event-scoped capability availability payload."""

    enabled_capability_ids: Optional[List[int]] = None  # null = all enabled

get_events async

get_events(db: Session = Depends(get_db))

Get all events

Source code in backend/app/api/v1/events.py
@router.get("/")
async def get_events(
    db: Session = Depends(get_db),
):
    """Get all events"""
    events = db.query(Event).all()
    return events

get_event async

get_event(event_id: int, db: Session = Depends(get_db))

Get a specific event

Source code in backend/app/api/v1/events.py
@router.get("/{event_id}")
async def get_event(
    event_id: int, 
    db: Session = Depends(get_db),
):
    """Get a specific event"""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    return event

create_event async

create_event(event_data: EventCreate, db: Session = Depends(get_db))

Create a new event

Source code in backend/app/api/v1/events.py
@router.post("/")
async def create_event(
    event_data: EventCreate,
    db: Session = Depends(get_db),
):
    """Create a new event"""
    event = Event(
        name=event_data.name, 
        location=event_data.location,
        start_date=event_data.start_date,
        end_date=event_data.end_date,
        meta_data=event_data.meta_data or {}
    )
    db.add(event)
    db.commit()
    db.refresh(event)

    return event

update_event async

update_event(event_id: int, event_data: EventCreate, db: Session = Depends(get_db))

Update an existing event

Source code in backend/app/api/v1/events.py
@router.put("/{event_id}")
async def update_event(
    event_id: int,
    event_data: EventCreate,
    db: Session = Depends(get_db),
):
    """Update an existing event"""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    event.name = event_data.name
    event.location = event_data.location
    event.start_date = event_data.start_date
    event.end_date = event_data.end_date
    if event_data.meta_data is not None:
        event.meta_data = event_data.meta_data

    db.commit()
    db.refresh(event)

    return event

update_event_calendar async

update_event_calendar(event_id: int, data: EventCalendarUpdate, db: Session = Depends(get_db))

Update only the Google Calendar ID on an event.

Source code in backend/app/api/v1/events.py
@router.patch("/{event_id}/calendar")
async def update_event_calendar(
    event_id: int,
    data: EventCalendarUpdate,
    db: Session = Depends(get_db),
):
    """Update only the Google Calendar ID on an event."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")
    event.google_calendar_id = data.google_calendar_id
    db.commit()
    db.refresh(event)
    return {"status": "ok", "google_calendar_id": event.google_calendar_id}

delete_event async

delete_event(event_id: int, db: Session = Depends(get_db))

Delete an event and all associated data.

Source code in backend/app/api/v1/events.py
@router.delete("/{event_id}")
async def delete_event(
    event_id: int,
    db: Session = Depends(get_db),
):
    """Delete an event and all associated data."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    event_name = event.name

    try:
        delete_event_scoped_data(db, event_id)
        db.commit()

    except Exception as exc:
        db.rollback()
        logger.error(f"Error deleting event {event_id}: {exc}")
        raise HTTPException(status_code=500, detail=f"Failed to delete event: {exc}")

    db.expire_all()
    return {"status": "success", "message": f"Event '{event_name}' deleted successfully"}

update_event_status async

update_event_status(event_id: int, data: EventStatusUpdate, db: Session = Depends(get_db))

Update an event's status (draft | optimised | finalised | published).

Source code in backend/app/api/v1/events.py
@router.put("/{event_id}/status", response_model=EventStatusResponse)
async def update_event_status(
    event_id: int,
    data: EventStatusUpdate,
    db: Session = Depends(get_db),
):
    """Update an event's status (draft | optimised | finalised | published)."""
    valid_statuses = {"draft", "optimised", "finalised", "published"}
    if data.status not in valid_statuses:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid status. Must be one of: {valid_statuses}"
        )

    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    event.status = data.status
    db.commit()
    db.refresh(event)

    resp = EventStatusResponse(id=event.id, name=event.name, status=event.status)
    return resp.model_dump()

update_event_capabilities async

update_event_capabilities(event_id: int, data: EnabledCapabilitiesUpdate, db: Session = Depends(get_db))

Update the enabled capabilities for an event. null = all enabled.

Source code in backend/app/api/v1/events.py
@router.put("/{event_id}/capabilities")
async def update_event_capabilities(
    event_id: int,
    data: EnabledCapabilitiesUpdate,
    db: Session = Depends(get_db),
):
    """Update the enabled capabilities for an event. null = all enabled."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    event.enabled_capability_ids = data.enabled_capability_ids
    db.commit()
    db.refresh(event)

    return {
        "status": "ok",
        "event_id": event.id,
        "enabled_capability_ids": event.enabled_capability_ids,
    }

Google Publishing

google

Google Calendar API Endpoints Handles OAuth2 connection, calendar selection, and publishing to Google Calendar.

ConnectResponse

Bases: BaseModel

OAuth authorisation URL and state returned when starting Google connect.

Source code in backend/app/api/v1/google.py
class ConnectResponse(BaseModel):
    """OAuth authorisation URL and state returned when starting Google connect."""

    auth_url: str
    state: str

OAuthCallbackRequest

Bases: BaseModel

Authorisation code and state returned from Google's OAuth redirect.

Source code in backend/app/api/v1/google.py
class OAuthCallbackRequest(BaseModel):
    """Authorisation code and state returned from Google's OAuth redirect."""

    code: str
    state: str

ConnectionResponse

Bases: BaseModel

Stored Google Calendar account connection exposed to the frontend.

Source code in backend/app/api/v1/google.py
class ConnectionResponse(BaseModel):
    """Stored Google Calendar account connection exposed to the frontend."""

    id: int
    account_email: str
    calendar_id: Optional[str] = None
    calendar_name: Optional[str] = None

CalendarInfo

Bases: BaseModel

Calendar metadata returned by the Google Calendar API.

Source code in backend/app/api/v1/google.py
class CalendarInfo(BaseModel):
    """Calendar metadata returned by the Google Calendar API."""

    id: str
    summary: str
    description: str = ""
    primary: bool = False
    accessRole: str = ""

CalendarMember

Bases: BaseModel

Access-control entry for a Google Calendar member.

Source code in backend/app/api/v1/google.py
class CalendarMember(BaseModel):
    """Access-control entry for a Google Calendar member."""

    email: str
    role: str

SetCalendarRequest

Bases: BaseModel

Calendar selection payload for a stored Google connection.

Source code in backend/app/api/v1/google.py
class SetCalendarRequest(BaseModel):
    """Calendar selection payload for a stored Google connection."""

    calendar_id: str
    calendar_name: Optional[str] = None

PublishRequest

Bases: BaseModel

Google Calendar publish request for one event and optional date subset.

Source code in backend/app/api/v1/google.py
class PublishRequest(BaseModel):
    """Google Calendar publish request for one event and optional date subset."""

    event_id: int
    dates: Optional[List[str]] = None        # Specific dates to publish (YYYY-MM-DD); if null, publish all days

PublishDayResult

Bases: BaseModel

Per-day Google Calendar publish result.

Source code in backend/app/api/v1/google.py
class PublishDayResult(BaseModel):
    """Per-day Google Calendar publish result."""

    date: str
    deleted: int
    created: int
    errors: List[str] = Field(default_factory=list)

PublishResponse

Bases: BaseModel

Aggregate Google Calendar publish response.

Source code in backend/app/api/v1/google.py
class PublishResponse(BaseModel):
    """Aggregate Google Calendar publish response."""

    status: str
    results: List[PublishDayResult]
    events_created: int

get_connections async

get_connections(db: Session = Depends(get_db))

Get all Google Calendar connections.

Source code in backend/app/api/v1/google.py
@router.get("/connections", response_model=List[ConnectionResponse])
async def get_connections(db: Session = Depends(get_db)):
    """Get all Google Calendar connections."""
    connections = db.query(GoogleCalendarConnection).all()

    # Try to re-resolve any "unknown" account emails using stored tokens
    for conn in connections:
        if conn.account_email == "unknown" and conn.token_data:
            try:
                from google.oauth2.credentials import Credentials
                from googleapiclient.discovery import build
                token_data = get_connection_token_data(db, conn)
                creds = Credentials(
                    token=token_data.get("access_token"),
                    refresh_token=token_data.get("refresh_token"),
                    token_uri=token_data.get("token_uri", "https://oauth2.googleapis.com/token"),
                    client_id=token_data.get("client_id"),
                    client_secret=token_data.get("client_secret"),
                )
                service = build("calendar", "v3", credentials=creds)
                primary = service.calendarList().get(calendarId="primary").execute()
                resolved = primary.get("id")
                if resolved and resolved != "unknown":
                    conn.account_email = resolved
                    db.commit()
                    db.refresh(conn)
                    logger.info(f"Re-resolved account email for connection {conn.id}: {resolved}")
            except SecureCredentialStoreUnavailable as e:
                logger.debug(f"Could not load secure Google tokens for connection {conn.id}: {e}")
            except Exception as e:
                logger.debug(f"Could not re-resolve email for connection {conn.id}: {e}")

    return connections

start_connect async

start_connect()

Start OAuth2 flow - returns the Google authorisation URL.

Source code in backend/app/api/v1/google.py
@router.post("/connect", response_model=ConnectResponse)
async def start_connect():
    """Start OAuth2 flow  -  returns the Google authorisation URL."""
    try:
        logger.info("POST /connect called  -  creating auth URL")
        auth_url, state = create_auth_url()
        logger.info("Auth URL created")
        return ConnectResponse(auth_url=auth_url, state=state)
    except Exception as e:
        logger.exception("Failed to create auth URL")
        raise HTTPException(status_code=500, detail=f"Failed to create auth URL: {e}")

oauth2_callback_redirect async

oauth2_callback_redirect(code: str = Query(...), state: str = Query(...))

GET handler for the Google OAuth2 redirect. Serves a small HTML page that posts the authorisation code back to the opener (Electron / browser) window via postMessage, then closes itself.

Source code in backend/app/api/v1/google.py
@router.get("/oauth2callback")
async def oauth2_callback_redirect(code: str = Query(...), state: str = Query(...)):
    """
    GET handler for the Google OAuth2 redirect.
    Serves a small HTML page that posts the authorisation code back to the
    opener (Electron / browser) window via postMessage, then closes itself.
    """
    import json
    from fastapi.responses import HTMLResponse
    logger.info("GET /oauth2callback - received OAuth redirect")
    code_json = json.dumps(code)
    state_json = json.dumps(state)
    html = f"""<!DOCTYPE html>
<html><head><title>Connecting...</title></head>
<body>
<p>Authenticating with Google&hellip; this window will close automatically.</p>
<script>
  var msg = {{
    type: "google-calendar-callback",
    code: {code_json},
    state: {state_json}
  }};
  if (window.opener) {{
    window.opener.postMessage(msg, "*");
    setTimeout(function() {{ window.close(); }}, 1500);
  }} else {{
    // Electron may not set window.opener  -  try BroadcastChannel as fallback
    try {{
      var bc = new BroadcastChannel("google-oauth");
      bc.postMessage(msg);
      bc.close();
      setTimeout(function() {{ window.close(); }}, 1500);
    }} catch(e) {{
      document.body.innerHTML = "<p>Connection successful. Please close this window and return to the app.</p>";
    }}
  }}
</script>
</body></html>"""
    return HTMLResponse(content=html)

oauth2_callback async

oauth2_callback(request: OAuthCallbackRequest, db: Session = Depends(get_db))

Exchange authorisation code for tokens and store the connection.

Source code in backend/app/api/v1/google.py
@router.post("/oauth2callback", response_model=ConnectionResponse)
async def oauth2_callback(
    request: OAuthCallbackRequest,
    db: Session = Depends(get_db),
):
    """Exchange authorisation code for tokens and store the connection."""
    logger.info("POST /oauth2callback called")
    try:
        token_data = exchange_code_for_token(request.code, request.state)
        logger.info("Token exchange succeeded")
    except ValueError as e:
        logger.warning(f"Token exchange rejected: {e}")
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        logger.exception("Token exchange failed")
        raise HTTPException(status_code=400, detail=f"Failed to exchange code: {e}")

    # Get account email via Calendar API (primary calendar id == user email)
    from google.oauth2.credentials import Credentials
    from googleapiclient.discovery import build

    creds = Credentials(
        token=token_data["access_token"],
        refresh_token=token_data.get("refresh_token"),
        token_uri=token_data.get("token_uri", "https://oauth2.googleapis.com/token"),
        client_id=token_data.get("client_id"),
        client_secret=token_data.get("client_secret"),
    )
    account_email = "unknown"
    try:
        service = build("calendar", "v3", credentials=creds)
        primary = service.calendarList().get(calendarId="primary").execute()
        account_email = primary.get("id", "unknown")
        logger.info(f"Got account email from Calendar API: {account_email}")
    except Exception as e:
        logger.warning(f"Failed to get account email from Calendar API: {e}")

    # Fallback: try Google OAuth2 userinfo endpoint
    if account_email == "unknown":
        try:
            import requests as http_requests
            resp = http_requests.get(
                "https://www.googleapis.com/oauth2/v2/userinfo",
                headers={"Authorization": f"Bearer {token_data['access_token']}"},
                timeout=10,
            )
            if resp.ok:
                userinfo = resp.json()
                account_email = userinfo.get("email", "unknown")
                logger.info(f"Got account email from userinfo: {account_email}")
        except Exception as e2:
            logger.warning(f"Failed to get account email from userinfo: {e2}")

    # Check if connection already exists for this email
    existing = db.query(GoogleCalendarConnection).filter(
        GoogleCalendarConnection.account_email == account_email
    ).first()

    if existing:
        try:
            store_connection_token_secrets(existing, token_data)
            db.commit()
            db.refresh(existing)
            return existing
        except SecureCredentialStoreUnavailable as e:
            db.rollback()
            raise _credential_http_error(e)
    else:
        connection = GoogleCalendarConnection(
            account_email=account_email,
            token_data=sanitize_token_metadata(token_data, None),
        )
        try:
            db.add(connection)
            db.flush()
            store_connection_token_secrets(connection, token_data)
            db.commit()
            db.refresh(connection)
            return connection
        except SecureCredentialStoreUnavailable as e:
            db.rollback()
            raise _credential_http_error(e)

disconnect async

disconnect(connection_id: int, db: Session = Depends(get_db))

Remove a Google Calendar connection.

Source code in backend/app/api/v1/google.py
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect(connection_id: int, db: Session = Depends(get_db)):
    """Remove a Google Calendar connection."""
    connection = db.query(GoogleCalendarConnection).filter(
        GoogleCalendarConnection.id == connection_id
    ).first()
    if not connection:
        raise HTTPException(status_code=404, detail="Connection not found")

    try:
        delete_connection_token_secrets(connection.id)
    except SecureCredentialStoreUnavailable as e:
        raise _credential_http_error(e)
    db.delete(connection)
    db.commit()

get_calendars async

get_calendars(connection_id: int = Query(...), db: Session = Depends(get_db))

List all calendars for a Google account connection.

Source code in backend/app/api/v1/google.py
@router.get("/calendars", response_model=List[CalendarInfo])
async def get_calendars(
    connection_id: int = Query(...),
    db: Session = Depends(get_db),
):
    """List all calendars for a Google account connection."""
    connection = db.query(GoogleCalendarConnection).filter(
        GoogleCalendarConnection.id == connection_id
    ).first()
    if not connection:
        raise HTTPException(status_code=404, detail="Connection not found")

    try:
        token_data = get_connection_token_data(db, connection)
        cals = list_calendars(
            token_data,
            on_token_update=_persist_google_token_update(db, connection),
        )
        return cals
    except SecureCredentialStoreUnavailable as e:
        raise _credential_http_error(e)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to list calendars: {e}")

get_calendar_members async

get_calendar_members(connection_id: int = Query(...), calendar_id: str = Query(...), db: Session = Depends(get_db))

List people who have access to a specific calendar.

Source code in backend/app/api/v1/google.py
@router.get("/calendar-members", response_model=List[CalendarMember])
async def get_calendar_members(
    connection_id: int = Query(...),
    calendar_id: str = Query(...),
    db: Session = Depends(get_db),
):
    """List people who have access to a specific calendar."""
    connection = db.query(GoogleCalendarConnection).filter(
        GoogleCalendarConnection.id == connection_id
    ).first()
    if not connection:
        raise HTTPException(status_code=404, detail="Connection not found")

    try:
        token_data = get_connection_token_data(db, connection)
        members = list_calendar_members(
            token_data,
            calendar_id,
            on_token_update=_persist_google_token_update(db, connection),
        )
        return members
    except SecureCredentialStoreUnavailable as e:
        raise _credential_http_error(e)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to list calendar members: {e}")

set_calendar async

set_calendar(connection_id: int, request: SetCalendarRequest, db: Session = Depends(get_db))

Set the selected calendar for a connection.

Source code in backend/app/api/v1/google.py
@router.put("/connections/{connection_id}/calendar")
async def set_calendar(
    connection_id: int,
    request: SetCalendarRequest,
    db: Session = Depends(get_db),
):
    """Set the selected calendar for a connection."""
    connection = db.query(GoogleCalendarConnection).filter(
        GoogleCalendarConnection.id == connection_id
    ).first()
    if not connection:
        raise HTTPException(status_code=404, detail="Connection not found")

    connection.calendar_id = request.calendar_id
    connection.calendar_name = request.calendar_name
    db.commit()
    db.refresh(connection)
    return {"status": "ok", "calendar_id": connection.calendar_id}

get_calendar_colors async

get_calendar_colors(db: Session = Depends(get_db))

Fetch available event colours from Google Calendar API.

Source code in backend/app/api/v1/google.py
@router.get("/colors")
async def get_calendar_colors(db: Session = Depends(get_db)):
    """Fetch available event colours from Google Calendar API."""
    connection = db.query(GoogleCalendarConnection).first()
    if not connection:
        return []
    try:
        token_data = get_connection_token_data(db, connection)
        colors = get_event_colors(
            token_data,
            on_token_update=_persist_google_token_update(db, connection),
        )
        return colors
    except SecureCredentialStoreUnavailable as e:
        raise _credential_http_error(e)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to fetch colours: {e}")

publish_to_calendar async

publish_to_calendar(request: PublishRequest, db: Session = Depends(get_db))

Publish tasks to Google Calendar. If dates are specified, publish only those days. Otherwise, publish all days of the event. Deletes existing events for each day before writing.

Source code in backend/app/api/v1/google.py
@router.post("/publish", response_model=PublishResponse)
async def publish_to_calendar(
    request: PublishRequest,
    db: Session = Depends(get_db),
):
    """
    Publish tasks to Google Calendar.
    If dates are specified, publish only those days.
    Otherwise, publish all days of the event.
    Deletes existing events for each day before writing.
    """
    event = db.query(Event).filter(Event.id == request.event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    logger.info(f"Publish request: event_id={request.event_id}, dates={request.dates}")
    logger.info(
        "Event selected for Google publish: id=%s, name=%s, has_calendar=%s",
        event.id,
        event.name,
        bool(event.google_calendar_id),
    )

    if not event.google_calendar_id:
        raise HTTPException(status_code=400, detail="Event has no Google Calendar associated")

    # Find the connection for this calendar
    connection = db.query(GoogleCalendarConnection).filter(
        GoogleCalendarConnection.calendar_id == event.google_calendar_id
    ).first()
    if not connection:
        raise HTTPException(
            status_code=400,
            detail="No Google Calendar connection found for this event's calendar"
        )

    # Get all tasks for this event
    tasks = db.query(Task).filter(Task.event_id == event.id).all()

    # Build lookup dicts
    persons = db.query(Person).filter(Person.event_id == event.id).all()
    persons_by_id = {
        p.id: {
            "id": p.id,
            "first_name": p.first_name,
            "last_name": p.last_name,
            "email": p.email,
            "phone": p.phone,
            "google_email": p.google_email,
        }
        for p in persons
    }

    locations = db.query(Location).filter(Location.event_id == event.id).all()
    locations_by_id = {
        loc.id: {
            "id": loc.id,
            "name": loc.name,
            "address": loc.address,
        }
        for loc in locations
    }

    # Load export formats and task types for templating
    all_formats = db.query(CalendarExportFormat).all()
    export_formats_by_type = {
        f.task_type_id: {
            "title_template": f.title_template,
            "description_template": f.description_template,
            "color_id": f.color_id,
        }
        for f in all_formats
    }
    all_task_types = db.query(TaskType).all()
    task_types_by_id = {
        tt.id: {"id": tt.id, "name": tt.name, "color": tt.color}
        for tt in all_task_types
    }

    # Load task templates for field variable mapping
    all_templates = db.query(TaskTemplate).all()
    templates_by_id = {
        t.id: {"id": t.id, "fields": t.fields}
        for t in all_templates
    }

    # Determine which dates to publish
    if request.dates:
        target_dates = [date.fromisoformat(d) for d in request.dates]
    else:
        # All days of the event
        if not event.start_date or not event.end_date:
            raise HTTPException(status_code=400, detail="Event must have start and end dates")
        target_dates = []
        current = event.start_date
        while current <= event.end_date:
            target_dates.append(current)
            current += __import__("datetime").timedelta(days=1)

    # Group tasks by date
    tasks_by_date: Dict[str, list] = {}
    for task in tasks:
        additional = task.additional or {}
        task_date = additional.get("date")
        if task_date:
            tasks_by_date.setdefault(task_date, []).append(task)

    logger.info(f"Total tasks: {len(tasks)}, target_dates: {[d.isoformat() for d in target_dates]}")
    logger.info(f"Tasks grouped by date: { {k: len(v) for k, v in tasks_by_date.items()} }")

    # Publish each day
    results = []
    for target in target_dates:
        day_tasks = tasks_by_date.get(target.isoformat(), [])
        logger.info(f"Publishing {len(day_tasks)} tasks for {target.isoformat()}")
        task_dicts = [
            {
                "id": t.id,
                "title": t.title,
                "description": t.description,
                "task_type_id": t.task_type_id,
                "task_template_id": t.task_template_id,
                "constraints": t.constraints,
                "optimised": t.optimised,
                "final": t.final,
                "additional": t.additional,
                "is_floating": t.is_floating,
                "is_transfer": t.is_transfer,
            }
            for t in day_tasks
        ]

        try:
            token_data = get_connection_token_data(db, connection)
        except SecureCredentialStoreUnavailable as e:
            raise _credential_http_error(e)

        result = publish_day_to_calendar(
            token_data=token_data,
            calendar_id=event.google_calendar_id,
            target_date=target,
            tasks=task_dicts,
            persons_by_id=persons_by_id,
            locations_by_id=locations_by_id,
            export_formats=export_formats_by_type,
            task_types_by_id=task_types_by_id,
            templates_by_id=templates_by_id,
            on_token_update=_persist_google_token_update(db, connection),
        )
        results.append(PublishDayResult(**result))

    # Update event status to published
    event.status = "published"
    db.commit()

    events_created = sum(result.created for result in results)
    return PublishResponse(status="success", results=results, events_created=events_created)

Encryption

encryption

Column-level encryption for sensitive data stored in the local SQLite database. Uses Fernet (AES-128-CBC + HMAC-SHA256) via the cryptography library.

The encryption key is generated once and stored alongside the active database. Packaged desktop builds pass ENCRYPTION_KEY_PATH so updates keep the key in Electron's stable user-data directory. The key is NOT checked into version control.

EncryptedJSON

Bases: TypeDecorator

Transparently encrypts a dict/JSON value at rest in a TEXT column.

Source code in backend/app/core/encryption.py
class EncryptedJSON(TypeDecorator):
    """Transparently encrypts a dict/JSON value at rest in a TEXT column."""

    impl = Text
    cache_ok = True

    def process_bind_param(self, value, dialect):
        """Encrypt a JSON-compatible value before SQLAlchemy writes it."""
        if value is not None:
            return encrypt_json(value)
        return value

    def process_result_value(self, value, dialect):
        """Decrypt a JSON-compatible value after SQLAlchemy reads it."""
        if value is not None:
            return decrypt_json(value)
        return value

process_bind_param

process_bind_param(value, dialect)

Encrypt a JSON-compatible value before SQLAlchemy writes it.

Source code in backend/app/core/encryption.py
def process_bind_param(self, value, dialect):
    """Encrypt a JSON-compatible value before SQLAlchemy writes it."""
    if value is not None:
        return encrypt_json(value)
    return value

process_result_value

process_result_value(value, dialect)

Decrypt a JSON-compatible value after SQLAlchemy reads it.

Source code in backend/app/core/encryption.py
def process_result_value(self, value, dialect):
    """Decrypt a JSON-compatible value after SQLAlchemy reads it."""
    if value is not None:
        return decrypt_json(value)
    return value

EncryptedString

Bases: TypeDecorator

Transparently encrypts a string value at rest in a TEXT column.

Source code in backend/app/core/encryption.py
class EncryptedString(TypeDecorator):
    """Transparently encrypts a string value at rest in a TEXT column."""

    impl = Text
    cache_ok = True

    def process_bind_param(self, value, dialect):
        """Encrypt a string before SQLAlchemy writes it."""
        if value is not None:
            return encrypt_str(value)
        return value

    def process_result_value(self, value, dialect):
        """Decrypt a string after SQLAlchemy reads it."""
        if value is not None:
            return decrypt_str(value)
        return value

process_bind_param

process_bind_param(value, dialect)

Encrypt a string before SQLAlchemy writes it.

Source code in backend/app/core/encryption.py
def process_bind_param(self, value, dialect):
    """Encrypt a string before SQLAlchemy writes it."""
    if value is not None:
        return encrypt_str(value)
    return value

process_result_value

process_result_value(value, dialect)

Decrypt a string after SQLAlchemy reads it.

Source code in backend/app/core/encryption.py
def process_result_value(self, value, dialect):
    """Decrypt a string after SQLAlchemy reads it."""
    if value is not None:
        return decrypt_str(value)
    return value

encrypt_json

encrypt_json(data: dict) -> str

Encrypt a dict as a Fernet-encrypted JSON string.

Source code in backend/app/core/encryption.py
def encrypt_json(data: dict) -> str:
    """Encrypt a dict as a Fernet-encrypted JSON string."""
    if isinstance(data, str) and data.startswith(_ENC_PREFIX):
        return data
    plaintext = json.dumps(data).encode("utf-8")
    return _ENC_PREFIX + _get_fernet().encrypt(plaintext).decode("ascii")

decrypt_json

decrypt_json(value) -> dict

Decrypt a current Fernet-encrypted JSON value.

Source code in backend/app/core/encryption.py
def decrypt_json(value) -> dict:
    """Decrypt a current Fernet-encrypted JSON value."""
    if isinstance(value, str) and value.startswith(_ENC_PREFIX):
        token = value[len(_ENC_PREFIX):].encode("ascii")
        try:
            return json.loads(_get_fernet().decrypt(token))
        except InvalidToken:
            raise ValueError("Failed to decrypt value - encryption key may have changed")
    raise ValueError("Unencrypted JSON is not accepted by the current schema")

encrypt_str

encrypt_str(data: str) -> str

Encrypt a plain string.

Source code in backend/app/core/encryption.py
def encrypt_str(data: str) -> str:
    """Encrypt a plain string."""
    if not data:
        return data
    if data.startswith(_ENC_PREFIX):
        return data
    return _ENC_PREFIX + _get_fernet().encrypt(data.encode("utf-8")).decode("ascii")

decrypt_str

decrypt_str(value: Optional[str]) -> Optional[str]

Decrypt a current Fernet-encrypted string.

Source code in backend/app/core/encryption.py
def decrypt_str(value: Optional[str]) -> Optional[str]:
    """Decrypt a current Fernet-encrypted string."""
    if not value:
        return value
    if value.startswith(_ENC_PREFIX):
        token = value[len(_ENC_PREFIX):].encode("ascii")
        try:
            return _get_fernet().decrypt(token).decode("utf-8")
        except InvalidToken:
            raise ValueError("Failed to decrypt value - encryption key may have changed")
    raise ValueError("Unencrypted text is not accepted by the current schema")

Google Calendar Service

google_calendar_service

Google Calendar Service Handles OAuth2 authentication, calendar listing, and publishing tasks to Google Calendar.

create_auth_url

create_auth_url() -> tuple[str, str]

Create an OAuth2 authorisation URL. Returns (auth_url, state) tuple.

Source code in backend/app/core/google_calendar_service.py
def create_auth_url() -> tuple[str, str]:
    """
    Create an OAuth2 authorisation URL.
    Returns (auth_url, state) tuple.
    """
    flow = Flow.from_client_config(
        _get_client_config(),
        scopes=SCOPES,
        redirect_uri=CLIENT_CONFIG["installed"]["redirect_uris"][0],
    )
    auth_url, state = flow.authorization_url(
        access_type="offline",
        include_granted_scopes="true",
        prompt="consent",
    )
    # Store the PKCE code_verifier so exchange_code_for_token can use it
    code_verifier = flow.code_verifier
    _cleanup_pending_verifiers()
    _pending_verifiers[state] = (code_verifier, time.time() + _OAUTH_STATE_TTL_SECONDS)
    if not code_verifier:
        logger.warning("Flow did not generate a code_verifier (PKCE disabled?)")
    return auth_url, state

exchange_code_for_token

exchange_code_for_token(code: str, state: str = '') -> dict

Exchange an authorisation code for tokens. Returns token data dict (access_token, refresh_token, etc.).

Source code in backend/app/core/google_calendar_service.py
def exchange_code_for_token(code: str, state: str = "") -> dict:
    """
    Exchange an authorisation code for tokens.
    Returns token data dict (access_token, refresh_token, etc.).
    """
    config = _get_client_config()
    redirect_uri = CLIENT_CONFIG["installed"]["redirect_uris"][0]
    logger.info("exchange_code_for_token: redirect_uri=%s, client_id set=%s, client_secret set=%s",
                redirect_uri, bool(config['installed']['client_id']), bool(config['installed']['client_secret']))
    flow = Flow.from_client_config(
        config,
        scopes=SCOPES,
        redirect_uri=redirect_uri,
    )
    # Restore the PKCE code_verifier from the auth step
    code_verifier = _consume_code_verifier(state)
    if code_verifier:
        flow.code_verifier = code_verifier
        logger.info("Restored code_verifier for this exchange")
    else:
        logger.warning("No code_verifier found for state  -  PKCE may fail")
    try:
        flow.fetch_token(code=code)
    except Exception as e:
        logger.error(f"flow.fetch_token FAILED: {type(e).__name__}: {e}")
        raise
    creds = flow.credentials
    logger.info(f"Token exchange success  -  has refresh_token: {bool(creds.refresh_token)}")
    return {
        "access_token": creds.token,
        "refresh_token": creds.refresh_token,
        "token_uri": creds.token_uri,
        "client_id": creds.client_id,
        "client_secret": creds.client_secret,
        "scopes": list(creds.scopes) if creds.scopes else SCOPES,
        "expiry": creds.expiry.isoformat() if creds.expiry else None,
    }

list_calendars

list_calendars(token_data: dict, on_token_update=None) -> List[Dict[str, Any]]

List all calendars accessible to the authenticated user.

Source code in backend/app/core/google_calendar_service.py
def list_calendars(token_data: dict, on_token_update=None) -> List[Dict[str, Any]]:
    """List all calendars accessible to the authenticated user."""
    service, creds = _get_calendar_service(token_data)
    result = service.calendarList().list().execute()
    if on_token_update:
        on_token_update(_token_data_from_credentials(token_data, creds))
    calendars = []
    for cal in result.get("items", []):
        calendars.append({
            "id": cal["id"],
            "summary": cal.get("summary", ""),
            "description": cal.get("description", ""),
            "primary": cal.get("primary", False),
            "accessRole": cal.get("accessRole", ""),
        })
    return calendars

list_calendar_members

list_calendar_members(token_data: dict, calendar_id: str, on_token_update=None) -> List[Dict[str, str]]

List people who have access to a calendar (ACL entries with email).

Source code in backend/app/core/google_calendar_service.py
def list_calendar_members(
    token_data: dict,
    calendar_id: str,
    on_token_update=None,
) -> List[Dict[str, str]]:
    """List people who have access to a calendar (ACL entries with email)."""
    service, creds = _get_calendar_service(token_data)
    result = service.acl().list(calendarId=calendar_id).execute()
    if on_token_update:
        on_token_update(_token_data_from_credentials(token_data, creds))
    members = []
    for rule in result.get("items", []):
        scope = rule.get("scope", {})
        if scope.get("type") == "user":
            members.append({
                "email": scope.get("value", ""),
                "role": rule.get("role", ""),
            })
    return members

get_event_colors

get_event_colors(token_data: dict, on_token_update=None) -> List[Dict[str, Any]]

Fetch available event colours from Google Calendar API.

Source code in backend/app/core/google_calendar_service.py
def get_event_colors(token_data: dict, on_token_update=None) -> List[Dict[str, Any]]:
    """Fetch available event colours from Google Calendar API."""
    service, creds = _get_calendar_service(token_data)
    result = service.colors().get().execute()
    if on_token_update:
        on_token_update(_token_data_from_credentials(token_data, creds))
    colors = []
    event_colors = result.get("event", {})
    for color_id, color_data in sorted(event_colors.items(), key=lambda x: int(x[0])):
        colors.append({
            "id": color_id,
            "background": color_data.get("background", ""),
            "foreground": color_data.get("foreground", ""),
        })
    return colors

publish_day_to_calendar

publish_day_to_calendar(token_data: dict, calendar_id: str, target_date: date, tasks: List[Dict[str, Any]], persons_by_id: Dict[int, Dict[str, Any]], locations_by_id: Dict[int, Dict[str, Any]], export_formats: Optional[Dict[int, Dict[str, Any]]] = None, task_types_by_id: Optional[Dict[int, Dict[str, Any]]] = None, templates_by_id: Optional[Dict[int, Dict[str, Any]]] = None, on_token_update=None) -> Dict[str, Any]

Publish tasks for a single day to Google Calendar. 1. Delete all events for target_date in this calendar 2. Create new events from tasks

Returns summary of created/deleted events.

Source code in backend/app/core/google_calendar_service.py
def publish_day_to_calendar(
    token_data: dict,
    calendar_id: str,
    target_date: date,
    tasks: List[Dict[str, Any]],
    persons_by_id: Dict[int, Dict[str, Any]],
    locations_by_id: Dict[int, Dict[str, Any]],
    export_formats: Optional[Dict[int, Dict[str, Any]]] = None,
    task_types_by_id: Optional[Dict[int, Dict[str, Any]]] = None,
    templates_by_id: Optional[Dict[int, Dict[str, Any]]] = None,
    on_token_update=None,
) -> Dict[str, Any]:
    """
    Publish tasks for a single day to Google Calendar.
    1. Delete all events for target_date in this calendar
    2. Create new events from tasks

    Returns summary of created/deleted events.
    """
    service, creds = _get_calendar_service(token_data)

    logger.info("publish_day_to_calendar: date=%s, tasks=%s", target_date, len(tasks))

    # Get calendar timezone so events are created in the correct local time
    cal_timezone = "UTC"
    try:
        calendar_info = service.calendars().get(calendarId=calendar_id).execute()
        cal_timezone = calendar_info.get("timeZone", "UTC")
        logger.info(f"Calendar timezone: {cal_timezone}")
    except HttpError as e:
        logger.warning(f"Failed to get calendar timezone, defaulting to UTC: {e}")

    # Define time boundaries for the day using the calendar's timezone
    # so the query window matches the timezone events were created in.
    try:
        from zoneinfo import ZoneInfo
        tz = ZoneInfo(cal_timezone)
        day_start = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=tz)
        day_end = datetime.combine(target_date + timedelta(days=1), datetime.min.time()).replace(tzinfo=tz)
        time_min = day_start.isoformat()
        time_max = day_end.isoformat()
    except Exception:
        # Fallback to UTC if timezone parsing fails
        time_min = datetime.combine(target_date, datetime.min.time()).isoformat() + "Z"
        time_max = datetime.combine(target_date + timedelta(days=1), datetime.min.time()).isoformat() + "Z"

    logger.info(f"Deleting events between {time_min} and {time_max}")

    # Step 1: Delete ALL existing events for this day (paginate to catch everything)
    deleted_count = 0
    try:
        page_token = None
        while True:
            events_result = service.events().list(
                calendarId=calendar_id,
                timeMin=time_min,
                timeMax=time_max,
                singleEvents=True,
                maxResults=2500,
                pageToken=page_token,
            ).execute()

            for event in events_result.get("items", []):
                try:
                    service.events().delete(
                        calendarId=calendar_id,
                        eventId=event["id"],
                    ).execute()
                    deleted_count += 1
                except HttpError as e:
                    logger.warning(f"Failed to delete event {event['id']}: {e}")

            page_token = events_result.get("nextPageToken")
            if not page_token:
                break
    except HttpError as e:
        logger.error(f"Failed to list events for deletion: {e}")

    # Step 2: Create new events from tasks
    created_count = 0
    errors = []

    for task in tasks:
        try:
            # Build field_id → variable name mapping for this task's template
            field_id_to_var = None
            tmpl_id = task.get("task_template_id")
            if templates_by_id and tmpl_id and tmpl_id in templates_by_id:
                tmpl = templates_by_id[tmpl_id]
                field_id_to_var = {}
                for f in (tmpl.get("fields") or []):
                    fid = f.get("id", "")
                    fname = f.get("name", fid)
                    if fid:
                        field_id_to_var[fid] = f"field.{_sanitize_field_name(fname)}"

            gcal_event = _task_to_gcal_event(
                task, target_date, persons_by_id, locations_by_id,
                export_formats=export_formats or {},
                task_types_by_id=task_types_by_id or {},
                field_id_to_var=field_id_to_var,
                cal_timezone=cal_timezone,
            )
            if gcal_event:
                created_event = service.events().insert(
                    calendarId=calendar_id,
                    body=gcal_event,
                    supportsAttachments=False,
                ).execute()
                created_count += 1
                logger.info(f"  Created GCal event: id={created_event.get('id')}, summary={gcal_event.get('summary')!r}")
            else:
                logger.debug(f"  Task '{task.get('title')}' (id={task.get('id')}): _task_to_gcal_event returned None, skipping")
        except HttpError as e:
            errors.append(f"Failed to create event for task '{task.get('title', '?')}': {e}")
            logger.error(f"Failed to create calendar event (HttpError): {e}")
        except Exception as e:
            errors.append(f"Failed to create event for task '{task.get('title', '?')}': {e}")
            logger.error(f"Failed to create calendar event (unexpected): {e}", exc_info=True)

    logger.info(f"publish_day_to_calendar result: date={target_date}, deleted={deleted_count}, created={created_count}, errors={len(errors)}")
    if errors:
        for err in errors:
            logger.error(f"  Publish error: {err}")

    if on_token_update:
        on_token_update(_token_data_from_credentials(token_data, creds))

    return {
        "date": target_date.isoformat(),
        "deleted": deleted_count,
        "created": created_count,
        "errors": errors,
    }

Optimisation Runner

optimization_runner

Background Optimisation Task Runner Handles the actual execution of optimisation in background threads

run_optimization_background

run_optimization_background(job_id: int, normalized_input: Dict[str, Any], test_mode: bool = False) -> None

Execute optimisation in background thread.

This function: 1. Updates job status to "running" 2. Calls compute service with real optimiser 3. Writes results to task.optimised and Assignment records 4. Updates job with results or error 5. Handles all exceptions gracefully

Parameters:

Name Type Description Default
job_id int

ID of the OptimizationJob record

required
normalized_input Dict[str, Any]

Normalised data ready for compute service

required
test_mode bool

Ignored - always uses real optimiser

False
Source code in backend/app/core/optimization_runner.py
def run_optimization_background(
    job_id: int,
    normalized_input: Dict[str, Any],
    test_mode: bool = False
) -> None:
    """
    Execute optimisation in background thread.

    This function:
    1. Updates job status to "running"
    2. Calls compute service with real optimiser
    3. Writes results to task.optimised and Assignment records
    4. Updates job with results or error
    5. Handles all exceptions gracefully

    Args:
        job_id: ID of the OptimizationJob record
        normalized_input: Normalised data ready for compute service
        test_mode: Ignored - always uses real optimiser
    """
    db = SessionLocal()

    try:
        # Get job and update to running
        job = db.query(OptimizationJob).filter(OptimizationJob.id == job_id).first()
        if not job:
            print(f"[Optimization Runner] ERROR: Job {job_id} not found")
            return

        job.status = "running"
        job.started_at = datetime.utcnow()

        # Generate a request_id so we can poll solver progress while POST blocks
        request_id = uuid.uuid4().hex
        job.compute_request_id = request_id
        db.commit()

        print(f"[Optimization Runner] Job {job_id} started for {job.date} (request_id={request_id})")
        print(f"[Optimization Runner] Calling real optimiser...")

        # Call compute service (blocking - passes request_id so compute
        # registers a ProgressCallback that the status endpoint can query)
        result = call_compute_service_sync(normalized_input, request_id)
        print(f"[Optimization Runner] Optimiser completed: {result.get('status')}")

        # Write results to database if successful
        # CP-SAT returns "OPTIMAL" or "FEASIBLE" for successful solves
        if result.get("status") in ("OPTIMAL", "FEASIBLE"):
            # In desktop mode the local DB has no task/person records (data
            # lives in localStorage / the remote server), so we skip the
            # DB write. The frontend reads results from job.result_data.
            if settings.ENVIRONMENT == "desktop":
                print(f"[Optimization Runner] Desktop mode  -  skipping DB write (frontend reads result_data)")
            else:
                print(f"[Optimization Runner] Writing results to database...")
                write_optimization_results(
                    db=db,
                    event_id=normalized_input.get("event_id"),
                    date=normalized_input.get("date"),
                    result=result
                )
                print(f"[Optimization Runner] Results written successfully")
        else:
            print(f"[Optimization Runner] Optimisation failed or infeasible, preserving old data")

        # Preserve solver outcomes as distinct terminal states. Operational
        # failures remain "failed" and keep using error_message.
        solver_status = result.get("status")
        if solver_status in ("OPTIMAL", "FEASIBLE"):
            job.status = "completed"
        elif solver_status == "INFEASIBLE":
            job.status = "infeasible"
        elif solver_status == "UNKNOWN":
            job.status = "undetermined"
        else:
            job.status = "failed"
            job.error_message = f"Unexpected solver status: {solver_status or 'missing'}"
        job.completed_at = datetime.utcnow()
        job.result_data = result
        # Persist final progress data so the frontend can display it after completion
        job.progress_data = {
            "snapshots": result.get("progress_snapshots", []),
            "is_running": False,
            "max_time_seconds": normalized_input.get("solver_config", {}).get("max_time_seconds", 30),
            "solver_status": result.get("status"),
            "diagnostics": result.get("diagnostics"),
        }
        db.commit()

        elapsed = (job.completed_at - job.started_at).total_seconds()
        print(f"[Optimization Runner] Job {job_id} finished as {job.status} in {elapsed:.1f}s")

    except Exception as e:
        # Handle errors gracefully - preserve old data on failure
        print(f"[Optimization Runner] ERROR in job {job_id}: {str(e)}")
        import traceback
        traceback.print_exc()

        try:
            job = db.query(OptimizationJob).filter(OptimizationJob.id == job_id).first()
            if job:
                job.status = "failed"
                job.error_message = str(e)
                job.completed_at = datetime.utcnow()
                db.commit()
                print(f"[Optimization Runner] Job marked as failed, old data preserved")
        except Exception as commit_error:
            print(f"[Optimization Runner] ERROR updating failed job: {str(commit_error)}")

    finally:
        db.close()

call_compute_service_sync

call_compute_service_sync(normalized_input: Dict[str, Any], request_id: str = None) -> Dict[str, Any]

Synchronous call to compute service.

Parameters:

Name Type Description Default
normalized_input Dict[str, Any]

Dictionary with tasks, persons, transfers, etc.

required
request_id str

Optional UUID for progress tracking on compute side.

None

Returns:

Type Description
Dict[str, Any]

Dictionary with optimisation results

Raises:

Type Description
HTTPError

If compute service call fails

Source code in backend/app/core/optimization_runner.py
def call_compute_service_sync(normalized_input: Dict[str, Any], request_id: str = None) -> Dict[str, Any]:
    """
    Synchronous call to compute service.

    Args:
        normalized_input: Dictionary with tasks, persons, transfers, etc.
        request_id: Optional UUID for progress tracking on compute side.

    Returns:
        Dictionary with optimisation results

    Raises:
        httpx.HTTPError: If compute service call fails
    """
    compute_service_url = settings.OPTIMIZER_URL

    print(f"[Optimization Runner] Calling compute service at {compute_service_url}")

    auth_headers = {}
    desktop_token = os.getenv("DESKTOP_AUTH_TOKEN")
    if desktop_token:
        auth_headers["x-desktop-token"] = desktop_token

    # Health check first  -  fast probe to detect a dead service early
    try:
        with httpx.Client(timeout=5.0) as probe:
            health = probe.get(f"{compute_service_url}/health", headers=auth_headers)
            info = health.json()
            print(f"[Optimization Runner] Compute service healthy  -  PID={info.get('pid')}, uptime={info.get('uptime_seconds')}s")
    except httpx.ConnectError:
        print(f"[Optimization Runner] HEALTH CHECK FAILED  -  compute service is NOT reachable at {compute_service_url}")
        print(f"[Optimization Runner] The compute process has likely crashed. Check Electron console for '[Optimizer LIFECYCLE]' messages.")
        raise RuntimeError(
            f"Compute service is not running at {compute_service_url}. "
            f"Restart the desktop app to recover."
        )
    except Exception as health_err:
        print(f"[Optimization Runner] Health check warning: {health_err}  -  proceeding anyway")

    try:
        with httpx.Client(timeout=3600.0) as client:  # 1 hour timeout
            response = client.post(
                f"{compute_service_url}/optimize/day",
                headers=auth_headers,
                json={
                    "event_id": normalized_input.get("event_id"),
                    "date": normalized_input.get("date"),
                    "normalized_input": normalized_input,
                    "request_id": request_id,
                }
            )

            # If error, try to get detailed error message from response
            if response.status_code != 200:
                try:
                    error_detail = response.json().get("detail", "Unknown error")
                    print(f"[Optimization Runner] Compute service error: {error_detail}")
                except:
                    error_detail = response.text
                    print(f"[Optimization Runner] Compute service error (raw): {error_detail}")

                response.raise_for_status()

            return response.json()
    except httpx.ConnectError as e:
        print(f"[Optimization Runner] CONNECTION REFUSED: {str(e)}")
        print(f"[Optimization Runner] The compute service at {compute_service_url} is not running.")
        print(f"[Optimization Runner] This usually means the process crashed. Check Electron DevTools for '[Optimizer LIFECYCLE]' exit logs.")
        raise
    except httpx.HTTPError as e:
        print(f"[Optimization Runner] HTTP error: {str(e)}")
        raise